2 Commits

Author SHA1 Message Date
Zoe
01a147d2d3 v0.3.1: More admin UI improvements and bug fixes
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 29s
This commit further refines the admin UI, and introduces a very SPA-like
creating process for links and categories. In-place editing has also
been improved, the styling is more correct and better formatted, as well
as having some cleaner code.

This PR also fixes a few bugs:
- Image uploads not being URL encoded, so special characters would break
  images
- If an image has exif, but no orientation tag, the image would be
  wrongfully rejected
- In-place editing forms were not correctly sized, and title inputs
  would not break with line breaks in the titles

This PR also greatly improves performance on the admin UI.
2025-09-30 19:45:58 -05:00
Zoe
462ed6491c Vastly overhaul admin UI
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 29s
Admin UI now has the ability to edit links that exist. Deleting items is
more accessible and asks for a confirmation before deleting. Link and
Category names as well as link descriptions now have a length limit
(todo: make it configurable?). Small bug fixes related to image saving
are also included in this commit.
2025-09-30 01:14:18 -05:00
12 changed files with 1405 additions and 170 deletions

View File

@@ -63,6 +63,9 @@ You can then run the binary.
| `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar, without any query parameters | true | | `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar, without any query parameters | true |
| `PASSPORT_SEARCH_PROVIDER_QUERY_PARAM` | The query parameter to use for the search provider, e.g. `q` for most providers | false | q | | `PASSPORT_SEARCH_PROVIDER_QUERY_PARAM` | The query parameter to use for the search provider, e.g. `q` for most providers | false | q |
> [!NOTE]
> Currently passport only supports search using a GET request.
#### Weather configuration #### Weather configuration
The weather integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the OpenWeatherMap integration. The weather integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the OpenWeatherMap integration.

2
go.mod
View File

@@ -5,6 +5,8 @@ go 1.25.0
require ( require (
github.com/HugoSmits86/nativewebp v1.2.0 github.com/HugoSmits86/nativewebp v1.2.0
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/disintegration/imaging v1.6.2
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/image v0.24.0 golang.org/x/image v0.24.0
modernc.org/sqlite v1.39.0 modernc.org/sqlite v1.39.0
) )

6
go.sum
View File

@@ -7,6 +7,8 @@ github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vaui
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
@@ -51,6 +53,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw= github.com/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw=
github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI= github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
@@ -74,6 +78,7 @@ golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
@@ -86,6 +91,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=

View File

@@ -1,4 +1,4 @@
//go:generate tailwindcss -i styles/main.css -o assets/tailwind.css --minify //go:generate tailwindcss -i styles/main.scss -o assets/tailwind.css --minify
package main package main
@@ -17,6 +17,7 @@ import (
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -27,6 +28,7 @@ import (
"github.com/HugoSmits86/nativewebp" "github.com/HugoSmits86/nativewebp"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"github.com/disintegration/imaging"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/helmet" "github.com/gofiber/fiber/v3/middleware/helmet"
"github.com/gofiber/fiber/v3/middleware/static" "github.com/gofiber/fiber/v3/middleware/static"
@@ -35,6 +37,8 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/juls0730/passport/src/middleware" "github.com/juls0730/passport/src/middleware"
"github.com/juls0730/passport/src/services" "github.com/juls0730/passport/src/services"
"github.com/rwcarlsen/goexif/exif"
"github.com/rwcarlsen/goexif/tiff"
"golang.org/x/image/draw" "golang.org/x/image/draw"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -44,23 +48,36 @@ var embeddedAssets embed.FS
var devContent = `<script> var devContent = `<script>
let host = window.location.hostname; let host = window.location.hostname;
const socket = new WebSocket('ws://' + host + ':2067/ws'); let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(protocol + '//' + host + ':2067/ws');
socket.addEventListener('message', (event) => { socket.addEventListener('message', (event) => {
if (event.data === 'refresh') { if (event.data === 'refresh') {
console.log('Got refresh signal');
let attempts = 0;
let delay = 100;
async function testPage() { async function testPage() {
try { try {
let res = await fetch(window.location.href) let res = await fetch(window.location.href)
} catch (error) { } catch (error) {
console.error(error); if (attempts > 5) {
setTimeout(testPage, 300); return;
}
setTimeout(testPage, delay);
// exponential backoff
attempts++;
delay = 100 * Math.pow(2, attempts);
return; return;
} }
window.location.reload(); window.location.reload();
} }
testPage(); setTimeout(testPage, 150);
} }
}); });
</script>` </script>`
@@ -282,8 +299,45 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
return "", errors.New("unsupported file type") return "", errors.New("unsupported file type")
} }
if err != nil { if contentType != "image/svg+xml" {
return "", err off, err := srcFile.Seek(0, io.SeekStart)
if err != nil {
return "", fmt.Errorf("failed to seek to start of file: %v", err)
}
if off != 0 {
return "", fmt.Errorf("failed to seek to start of file: %v", err)
}
x, err := exif.Decode(srcFile)
// if there *is* exif, parse it
if err == nil {
tag, err := x.Get(exif.Orientation)
if err == nil {
if tag.Count == 1 && tag.Format() == tiff.IntVal {
orientation, err := tag.Int(0)
if err != nil {
return "", fmt.Errorf("failed to get orientation: %v", err)
}
slog.Debug("Orientation tag found", "orientation", orientation)
switch orientation {
case 3:
img = imaging.Rotate180(img)
case 6:
img = imaging.Rotate270(img)
case 8:
img = imaging.Rotate90(img)
}
}
}
}
img, err = CropToCenter(img, 96)
if err != nil {
return "", err
}
} }
assetsDir := "public/uploads" assetsDir := "public/uploads"
@@ -291,7 +345,21 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
iconPath := filepath.Join(assetsDir, fileName) iconPath := filepath.Join(assetsDir, fileName)
if contentType == "image/svg+xml" { if contentType == "image/svg+xml" {
if err = c.SaveFile(file, iconPath); err != nil { // replace currentColor with a text color
outFile, err := os.Create(iconPath)
if err != nil {
return "", err
}
defer outFile.Close()
svgText, err := io.ReadAll(srcFile)
if err != nil {
return "", err
}
svgText = bytes.ReplaceAll(svgText, []byte("currentColor"), []byte(`oklch(87% 0.015 286)`))
_, err = outFile.Write(svgText)
if err != nil {
return "", err return "", err
} }
} else { } else {
@@ -301,16 +369,9 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
} }
defer outFile.Close() defer outFile.Close()
// crop slightly larger than 64px to vastly increase the quality of the image, but not increase the file size
// *too* much and so that we dont have a ton of extra file data that will never be seen by the user
resizedImg, err := CropToCenter(img, 96)
if err != nil {
return "", err
}
var buf bytes.Buffer var buf bytes.Buffer
options := &nativewebp.Options{} options := &nativewebp.Options{}
if err := nativewebp.Encode(&buf, resizedImg, options); err != nil { if err := nativewebp.Encode(&buf, img, options); err != nil {
return "", err return "", err
} }
@@ -319,7 +380,7 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
} }
} }
iconPath = "/uploads/" + fileName iconPath = "/uploads/" + url.PathEscape(fileName)
return iconPath, nil return iconPath, nil
} }
@@ -790,6 +851,14 @@ func main() {
}) })
} }
req.Name = strings.TrimSpace(req.Name)
if len(req.Name) > 50 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Name is too long. Maximum length is 50 characters",
})
}
file, err := c.FormFile("icon") file, err := c.FormFile("icon")
if err != nil || file == nil { if err != nil || file == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -810,7 +879,7 @@ func main() {
}) })
} }
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
iconPath, err := UploadFile(file, filename, contentType, c) iconPath, err := UploadFile(file, filename, contentType, c)
if err != nil { if err != nil {
@@ -819,8 +888,6 @@ func main() {
}) })
} }
UploadFile(file, iconPath, contentType, c)
category, err := app.CategoryManager.CreateCategory(Category{ category, err := app.CategoryManager.CreateCategory(Category{
Name: req.Name, Name: req.Name,
Icon: iconPath, Icon: iconPath,
@@ -857,6 +924,23 @@ func main() {
}) })
} }
req.Name = strings.TrimSpace(req.Name)
if req.Description != "" {
req.Description = strings.TrimSpace(req.Description)
}
if len(req.Name) > 50 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Name is too long. Maximum length is 50 characters",
})
}
if len(req.Description) > 150 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Description is too long. Maximum length is 150 characters",
})
}
categoryID, err := strconv.ParseInt(c.Params("id"), 10, 64) categoryID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -890,7 +974,7 @@ func main() {
}) })
} }
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
iconPath, err := UploadFile(file, filename, contentType, c) iconPath, err := UploadFile(file, filename, contentType, c)
if err != nil { if err != nil {
@@ -900,8 +984,6 @@ func main() {
}) })
} }
UploadFile(file, iconPath, contentType, c)
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{ link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
CategoryID: categoryID, CategoryID: categoryID,
Name: req.Name, Name: req.Name,
@@ -922,6 +1004,245 @@ func main() {
}) })
}) })
api.Patch("/category/:id", func(c fiber.Ctx) error {
var req struct {
Name string `form:"name"`
}
if c.Params("id") == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "ID is required",
})
}
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": fmt.Sprintf("Failed to parse category ID: %v", err),
})
}
if err := c.Bind().Form(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Failed to parse request",
})
}
if req.Name != "" {
if len(req.Name) > 50 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Name is too long. Maximum length is 50 characters",
})
}
}
category := app.CategoryManager.GetCategory(id)
if category == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Category not found",
})
}
tx, err := app.db.Begin()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to start transaction",
})
}
defer tx.Rollback()
file, err := c.FormFile("icon")
if err == nil {
if file.Size > 5*1024*1024 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "File size too large. Maximum size is 5MB",
})
}
contentType := file.Header.Get("Content-Type")
if contentType != "image/svg+xml" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Only svg files are allowed",
})
}
oldIconPath := category.Icon
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
iconPath, err := UploadFile(file, filename, contentType, c)
if err != nil {
slog.Error("Failed to upload file", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to upload file, please try again!",
})
}
_, err = tx.Exec("UPDATE categories SET icon = ? WHERE id = ?", iconPath, id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to update category",
})
}
err = os.Remove(filepath.Join("public/", oldIconPath))
if err != nil {
slog.Error("Failed to delete icon", "error", err)
}
}
if req.Name != "" {
_, err = tx.Exec("UPDATE categories SET name = ? WHERE id = ?", req.Name, category.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to update category",
})
}
}
err = tx.Commit()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to commit transaction",
})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Category updated successfully",
})
})
api.Patch("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error {
var req struct {
Name string `form:"name"`
Description string `form:"description"`
Icon string `form:"icon"`
}
if err := c.Bind().Form(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Failed to parse request",
})
}
if len(req.Name) > 50 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Name is too long. Maximum length is 50 characters",
})
}
if len(req.Description) > 150 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Description is too long. Maximum length is 150 characters",
})
}
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": fmt.Sprintf("Failed to parse link ID: %v", err),
})
}
categoryID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": fmt.Sprintf("Failed to parse category ID: %v", err),
})
}
if app.CategoryManager.GetCategory(categoryID) == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Category not found",
})
}
link := app.CategoryManager.GetLink(linkID)
if link == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Link not found",
})
}
tx, err := app.db.Begin()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to start transaction",
})
}
defer tx.Rollback()
file, err := c.FormFile("icon")
if err == nil {
if file.Size > 5*1024*1024 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "File size too large. Maximum size is 5MB",
})
}
contentType := file.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Only image files are allowed",
})
}
oldIconPath := link.Icon
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
iconPath, err := UploadFile(file, filename, contentType, c)
if err != nil {
slog.Error("Failed to upload file", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to upload file, please try again!",
})
}
_, err = tx.Exec("UPDATE links SET icon = ? WHERE id = ?", iconPath, linkID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to update link",
})
}
err = os.Remove(filepath.Join("public/", oldIconPath))
if err != nil {
slog.Error("Failed to delete icon", "error", err)
}
}
if req.Name != "" {
_, err = tx.Exec("UPDATE links SET name = ? WHERE id = ?", req.Name, linkID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to update link",
})
}
}
if req.Description != "" {
_, err = tx.Exec("UPDATE links SET description = ? WHERE id = ?", req.Description, linkID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to update link",
})
}
}
err = tx.Commit()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to commit transaction",
})
}
slog.Info("Link updated successfully", "id", linkID, "name", req.Name)
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Link updated successfully",
})
})
api.Delete("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error { api.Delete("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error {
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64) linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
if err != nil { if err != nil {

View File

@@ -4,11 +4,11 @@
@theme { @theme {
--color-accent: oklch(57.93% 0.258 294.12); --color-accent: oklch(57.93% 0.258 294.12);
--color-success: oklch(70.19% 0.158 160.44); --color-success: oklch(70.19% 0.158 160.44);
--color-error: oklch(63.43% 0.251 28.48); --color-error: oklch(53% 0.251 28.48);
--color-base: oklch(11% .007 285); --color-base: oklch(11% .007 285);
--color-surface: oklch(19% 0.007 314.66); --color-surface: oklch(19% 0.007 285.66);
--color-overlay: oklch(26% 0.008 314.66); --color-overlay: oklch(26% 0.008 285.66);
--color-muted: oklch(63% 0.015 286); --color-muted: oklch(63% 0.015 286);
--color-subtle: oklch(72% 0.015 286); --color-subtle: oklch(72% 0.015 286);
@@ -61,7 +61,7 @@ button {
} }
input:not(.search) { input:not(.search) {
@apply px-4 py-2 rounded-md w-full bg-surface border border-highlight-sm/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden; @apply px-4 py-2 rounded-md w-full bg-surface border border-highlight/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden;
&[type="file"] { &[type="file"] {
@apply p-0 cursor-pointer; @apply p-0 cursor-pointer;
@@ -84,15 +84,18 @@ input:not(.search) {
transition-duration: 150ms; transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1); transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
contain: layout style paint;
&:hover { &:not(.admin) {
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); &:hover {
transform: translateY(-4px); box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
} transform: translateY(-4px);
}
&:active { &:active {
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1); box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
transform: translateY(2px); transform: translateY(2px);
}
} }
} }
@@ -110,18 +113,47 @@ input:not(.search) {
} }
} }
.link-card img { /* Div that holds the image */
.link-card div[data-img-container] {
flex-shrink: 0;
margin-right: 0.5rem; margin-right: 0.5rem;
}
.link-card div[data-img-container] img {
user-select: none; user-select: none;
border-radius: 0.375rem; border-radius: 0.375rem;
aspect-ratio: 1/1; aspect-ratio: 1/1;
object-fit: cover; object-fit: cover;
} }
.link-card div { /* Div that holds the text */
.link-card div[data-text-container] {
word-break: break-all; word-break: break-all;
} }
.link-card div p { .link-card div[data-text-container] p {
color: var(--color-subtle); color: var(--color-subtle);
} }
.categoy-header {
display: flex;
align-items: center;
}
.category-header div[data-img-container] {
@apply shrink-0 relative mr-2 h-full flex items-center justify-center size-8;
}
.categoy-header div[data-img-container] img {
user-select: none;
object-fit: cover;
aspect-ratio: 1/1;
}
.category-header h2 {
text-transform: capitalize;
word-break: break-all;
border-width: 1px;
border-color: #0000;
}

View File

@@ -0,0 +1,17 @@
<div id="category-contents" class="hidden">
<h3>Create A category</h3>
<form id="category-form" action="/api/categories" method="post"
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
<div>
<label for="categoryName">Name</label>
<input required type="text" name="name" id="categoryName" maxlength="50" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input type="file" name="icon" id="linkIcon" accept=".svg" required />
</div>
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Create
category</button>
</form>
<span id="category-message"></span>
</div>

View File

@@ -0,0 +1,13 @@
<div id="category-delete-contents" class="hidden text-center">
<h3>Are you sure you want to delete this category?</h3>
<p class="mb-3">You are about to delete the category <strong id="category-name"></strong>. This action cannot be
undone.
All links associated with this category will also be deleted. Are you sure you want to continue?</p>
<div class="flex justify-end flex-col gap-y-2">
<button class="px-4 py-2 rounded-md w-full bg-error text-white border-0"
onclick="confirmDeleteCategory()">Delete
category</button>
<button class="px-4 py-2 rounded-md w-full bg-overlay border border-highlight text-white"
onclick="closeModal()">Cancel</button>
</div>
</div>

View File

@@ -0,0 +1,12 @@
<div id="link-delete-contents" class="hidden text-center">
<h3>Are you sure you want to delete this link?</h3>
<p class="mb-3">You are about to delete the link <strong id="link-name"></strong>. This action cannot be undone. Are
you sure you
want to continue?</p>
<div class="flex justify-end flex-col gap-y-2">
<button class="px-4 py-2 rounded-md w-full bg-error text-white border-0" onclick="confirmDeleteLink()">Delete
link</button>
<button class="px-4 py-2 rounded-md w-full bg-overlay border border-highlight text-white"
onclick="closeModal()">Cancel</button>
</div>
</div>

View File

@@ -0,0 +1,25 @@
<div id="link-contents" class="hidden">
<h3>Add A link</h3>
<form id="link-form" action="/api/links" method="post"
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
<div>
<label for="linkName">Name</label>
<input required type="text" name="name" id="linkName" maxlength="50" />
</div>
<div>
<label for="linkDesc">Description (optional)</label>
<input type="text" name="description" id="linkDesc" maxlength="150" />
</div>
<div>
<label for="linkURL">URL</label>
<input required type="url" name="url" id="linkURL" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input required type="file" name="icon" id="linkIcon" accept="image/*" />
</div>
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Add
link</button>
</form>
<span id="link-message"></span>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -76,17 +76,19 @@
<div class="w-full sm:w-4/5 p-2.5"> <div class="w-full sm:w-4/5 p-2.5">
{{#each Categories}} {{#each Categories}}
<div class="flex items-center mt-2 first:mt-0"> <div class="flex items-center mt-2 first:mt-0">
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}" <img class="object-contain mr-2 select-none size-8" width="32" height="32" draggable="false"
src="{{this.Icon}}" /> alt="{{this.Name}}" src="{{this.Icon}}" />
<h2 class="capitalize break-all">{{this.Name}}</h2> <h2 class="capitalize break-all">{{this.Name}}</h2>
</div> </div>
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2"> <div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
{{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank" {{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank"
rel="noopener noreferrer"> rel="noopener noreferrer">
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" /> <div data-img-container>
<div> <img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
</div>
<div data-text-container>
<h3>{{this.Name}}</h3> <h3>{{this.Name}}</h3>
<p>{{this.Description}}</p> <p class="min-h-5">{{this.Description}}</p>
</div> </div>
</a> </a>
{{else}} {{else}}

View File

@@ -1,6 +1,6 @@
{ {
"name": "passport", "name": "passport",
"version": "0.2.0", "version": "0.3.1",
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.", "description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
"author": "juls0730", "author": "juls0730",
"license": "BSL-1.0", "license": "BSL-1.0",
@@ -11,8 +11,8 @@
}, },
"scripts": { "scripts": {
"dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go", "dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go",
"build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport" "build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport src/main.go"
}, },
"pattern": "src/**/*.{go,hbs,css,svg,png,jpg,jpeg,webp,woff2,ico,webp}", "pattern": "src/**/*.{go,hbs,scss,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
"shutdown_signal": "SIGINT" "shutdown_signal": "SIGINT"
} }