Vastly overhaul admin UI
Some checks failed
Build and Push Docker Image to GHCR / build-and-push (push) Failing after 21s

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.
This commit is contained in:
Zoe
2025-09-30 01:06:52 -05:00
parent cd6ac6e771
commit 55e132d80b
12 changed files with 1139 additions and 162 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
@@ -27,6 +27,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 +36,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,22 +47,35 @@ 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,16 +298,69 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
return "", errors.New("unsupported file type") return "", errors.New("unsupported file type")
} }
if contentType != "image/svg+xml" {
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 {
return "", fmt.Errorf("failed to get orientation: %v", err)
}
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 { if err != nil {
return "", err return "", err
} }
}
assetsDir := "public/uploads" assetsDir := "public/uploads"
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 +370,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
} }
@@ -790,6 +852,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 +880,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 +889,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 +925,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 +975,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 +985,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 +1005,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;
@@ -85,6 +85,7 @@ input:not(.search) {
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);
&:not(.admin) {
&:hover { &:hover {
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 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); transform: translateY(-4px);
@@ -95,6 +96,7 @@ input:not(.search) {
transform: translateY(2px); transform: translateY(2px);
} }
} }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.link-card { .link-card {
@@ -110,18 +112,24 @@ input:not(.search) {
} }
} }
.link-card img { /* Div that holds the image */
.link-card div:has(img):first-child {
flex-shrink: 0;
margin-right: 0.5rem; margin-right: 0.5rem;
}
.link-card div:first-child 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:nth-child(2) {
word-break: break-all; word-break: break-all;
} }
.link-card div p { .link-card div:nth-child(2) p {
color: var(--color-subtle); color: var(--color-subtle);
} }

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>

View File

@@ -18,32 +18,129 @@
<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" key="category-{{this.ID}}"> <div class="flex items-center" key="category-{{this.ID}}">
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" <div class="shrink-0 relative mr-2 h-full flex items-center justify-center">
alt="{{this.Name}}" src="{{this.Icon}}" /> <img class="object-contain select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
<h2 class="capitalize break-all">{{this.Name}}</h2> src="{{this.Icon}}" />
<button onclick="deleteCategory({{this.ID}})" <button onclick="selectIcon()"
class="w-fit h-fit flex p-0.5 bg-base border border-highlight rounded-md hover:filter hover:brightness-125 cursor-pointer"><svg class="absolute inset-0 bg-highlight/80 hidden rounded-md text-base items-center justify-center"
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"> draggable="false">
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round" <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12" />
</svg>
</button>
</div>
<h2 class="capitalize break-all border border-transparent">{{this.Name}}</h2>
<div class="ml-2" data-edit-actions>
<div class="flex flex-row gap-2">
<button aria-label="Edit category" onclick="editCategory({{this.ID}})"
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
</g>
</svg>
</button>
<button aria-label="Delete category" onclick="deleteCategory({{this.ID}})"
class="text-error w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" /> d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
</svg></button> </svg>
</button>
</div>
<div class="hidden flex-row gap-2">
<button aria-label="Confirm category edit" onclick="confirmCategoryEdit({{this.ID}})"
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 text-success">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="m5 12l5 5L20 7" />
</svg>
</button>
<button aria-label="Cancel category edit" onclick="cancelCategoryEdit({{this.ID}})"
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 text-error">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0m15.364-6.364L5.636 18.364" />
</svg>
</button>
</div>
</div>
</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}} {{#each this.Links}}
<div key="link-{{this.ID}}" class="link-card relative"> <div key="link-{{this.ID}}" class="link-card relative admin">
<div class="relative">
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" /> <img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
<div> <button onclick="selectIcon()"
<h3>{{this.Name}}</h3> class="absolute inset-0 bg-highlight/80 hidden rounded-md text-base items-center justify-center"
<p>{{this.Description}}</p> draggable="false">
</div> <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"
<button onclick="deleteLink({{this.ID}}, {{this.CategoryID}})" viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
class="w-fit h-fit flex p-0.5 bg-base border border-highlight/70 rounded-md hover:filter hover:brightness-125 cursor-pointer absolute right-1 top-1"><svg <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12" />
</svg>
</button>
</div>
<div class="flex-grow">
<h3 class="border border-transparent">{{this.Name}}</h3>
<p class="min-h-5">{{this.Description}}</p>
</div>
<div class="absolute right-1 top-1" data-edit-actions>
<div class="flex flex-row gap-2">
<button aria-label="Edit link" onclick="editLink({{this.ID}}, {{this.CategoryID}})"
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
</g>
</svg>
</button>
<button aria-label="Delete link" onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
class="text-error w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" /> d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
</svg></button> </svg>
</button>
</div>
<div class="hidden flex-row gap-2">
<button aria-label="Confirm link edit"
onclick="confirmLinkEdit({{this.ID}}, {{this.CategoryID}})"
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 text-success">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="m5 12l5 5L20 7" />
</svg>
</button>
<button aria-label="Cancel link edit"
onclick="cancelLinkEdit({{this.ID}}, {{this.CategoryID}})"
class="w-fit h-fit flex p-1 bg-highlight-sm shadow-sm border border-highlight/70 rounded-full hover:filter hover:brightness-125 active:brightness-95 cursor-pointer transition-[filter] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 text-error">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0m15.364-6.364L5.636 18.364" />
</svg>
</button>
</div>
</div>
</div> </div>
{{/each}} {{/each}}
<div onclick="openModal('link', {{this.ID}})" <div onclick="openModal('link', {{this.ID}})"
@@ -71,52 +168,14 @@
</section> </section>
</div> </div>
<div id="modal-container" <input type="file" id="icon-upload" accept="image/*" style="display: none;" />
<div id="modal-container" role="dialog" aria-modal="true"
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center"> class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center">
<div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm"> <div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm">
<div id="category-contents" class="hidden"> {{> 'partials/modals/category-form' }}
<h3>Create A category</h3> {{> 'partials/modals/link-form' }}
<form id="category-form" action="/api/categories" method="post" {{> 'partials/modals/delete-link' }}
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1"> {{> 'partials/modals/delete-category' }}
<div>
<label for="categoryName">Name</label>
<input required type="text" name="name" id="categoryName" />
</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>
<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" />
</div>
<div>
<label for="linkDesc">Description (optional)</label>
<input type="text" name="description" id="linkDesc" />
</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>
</div> </div>
</div> </div>
@@ -125,9 +184,125 @@
let modalContainer = document.getElementById("modal-container"); let modalContainer = document.getElementById("modal-container");
let modal = modalContainer.querySelector("div"); let modal = modalContainer.querySelector("div");
let pageElement = document.getElementById("blur-target"); let pageElement = document.getElementById("blur-target");
let iconUploader = document.getElementById("icon-upload");
let targetCategoryID = null; let targetCategoryID = null;
let activeModal = null; let activeModal = null;
// errpr check the form and add the invalid class if it's invalid
/**
* Submits a form to the given URL
* @param {Event} event - The event that triggered the function
* @param {string} url - The URL to submit the form to
* @param {"category" | "link"} target - The target to close the modal for
* @returns {Promise<void>}
*/
async function submitRequest(event, url, target) {
event.preventDefault();
let data = new FormData(event.target);
let res = await fetch(url, {
method: "POST",
body: data
});
if (res.status === 201) {
closeModal(target);
document.getElementById(`${target}-form`).reset();
location.reload();
} else {
let json = await res.json();
document.getElementById(`${target}-message`).innerText = json.message;
}
}
/**
* Initializes the form for the given form
* @param {"category" | "link"} form - The form to initialize
* @returns {void}
*/
function addErrorListener(form) {
document.getElementById(`${form}-form`).querySelector("button").addEventListener("click", (event) => {
document.getElementById(`${form}-form`).querySelectorAll("[required]").forEach((el) => {
el.classList.add("invalid:border-[#861024]!");
});
});
}
addErrorListener("link");
document.getElementById("link-form").addEventListener("submit", async (event) => {
await submitRequest(event, `/api/category/${targetCategoryID}/link`, "link");
});
addErrorListener("category");
document.getElementById("category-form").addEventListener("submit", async (event) => {
await submitRequest(event, `/api/category`, "category");
});
// when the background is clicked, close the modal
modalContainer.addEventListener("click", (event) => {
if (event.target === modalContainer) {
closeModal();
}
});
function selectIcon() {
iconUploader.click();
}
/**
* Processes a file and returns a data URL.
* @param {File} file The file to process.
*/
async function processFile(file) {
let reader = new FileReader();
return new Promise((resolve, reject) => {
if (file.type === "image/svg+xml") {
reader.addEventListener("load", async (event) => {
let svgString = event.target.result;
console.log(svgString);
svgString = svgString.replaceAll("currentColor", "oklch(87% 0.015 286)");
console.log(svgString);
// turn svgString into a data URL
resolve("data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgString))));
})
reader.readAsText(file);
} else {
// these should be jpg, png, or webp
// make a DataURL out of it
reader.addEventListener("load", async (event) => {
resolve(event.target.result);
});
reader.readAsDataURL(file);
}
});
}
let targetedImageElement = null;
iconUploader.addEventListener("change", async (event) => {
let file = event.target.files[0];
if (file === null) {
return;
}
if (targetedImageElement === null) {
throw new Error("icon upload element was clicked, but no target image element was set");
}
console.log(file);
let dataURL = await processFile(file);
targetedImageElement.src = dataURL;
});
function openModal(modalKind, categoryID) { function openModal(modalKind, categoryID) {
activeModal = modalKind; activeModal = modalKind;
targetCategoryID = categoryID; targetCategoryID = categoryID;
@@ -137,8 +312,11 @@
modalContainer.classList.add("is-visible"); modalContainer.classList.add("is-visible");
modal.classList.add("is-visible"); modal.classList.add("is-visible");
if (document.getElementById(modalKind + "-form") !== null) {
document.getElementById(modalKind + "-form").reset(); document.getElementById(modalKind + "-form").reset();
} }
}
function closeModal() { function closeModal() {
pageElement.style.filter = ""; pageElement.style.filter = "";
@@ -151,93 +329,482 @@
activeModal = null; activeModal = null;
}, 300) }, 300)
if (document.getElementById(activeModal + "-form") !== null) {
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => { document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
el.classList.remove("invalid:border-[#861024]!"); el.classList.remove("invalid:border-[#861024]!");
}); });
}
targetCategoryID = null; targetCategoryID = null;
} }
modalContainer.addEventListener("click", (event) => { let currentlyEditingLink = {
if (event.target === modalContainer) { ID: null,
closeModal(); categoryID: null,
originalText: null,
originalDescription: null,
icon: null,
};
async function editLink(linkID, categoryID) {
if (currentlyEditingLink.ID !== null) {
// cancel the edit if it's already in progress
cancelLinkEdit(currentlyEditingLink.ID, currentlyEditingCategory.categoryID);
} }
let linkEl = document.querySelector(`[key=link-${linkID}]`);
let linkImg = linkEl.querySelector("div:first-child img");
let fileUploaderOverlay = linkImg.nextElementSibling;
let linkName = linkEl.querySelector("div:nth-child(2) h3");
let linkDesc = linkEl.querySelector("div:nth-child(2) p");
let editActions = linkEl.querySelector("[data-edit-actions]");
currentlyEditingLink.ID = linkID;
currentlyEditingCategory.categoryID = categoryID;
currentlyEditingLink.originalText = linkName.textContent;
currentlyEditingLink.originalDescription = linkDesc.textContent;
currentlyEditingLink.icon = linkImg.src;
console.log(currentlyEditingLink)
iconUploader.accept = "image/*";
targetedImageElement = linkImg;
editActions.querySelector("div").classList.add("hidden");
editActions.querySelector("div").classList.remove("flex");
editActions.querySelector("div:nth-child(2)").classList.remove("hidden");
editActions.querySelector("div:nth-child(2)").classList.add("flex");
fileUploaderOverlay.classList.remove("hidden");
fileUploaderOverlay.classList.add("flex");
replaceWithResizableInput(linkName);
replaceWithResizableTextarea(linkDesc);
}
async function confirmLinkEdit(linkID, categoryID) {
let linkEl = document.querySelector(`[key=link-${linkID}]`);
let linkImg = linkEl.querySelector("div:first-child img");
let fileUploaderOverlay = linkImg.nextElementSibling;
let linkNameInput = linkEl.querySelector("input");
let linkDescInput = linkEl.querySelector("textarea");
let editActions = linkEl.querySelector("[data-edit-actions]");
linkNameInput.value = linkNameInput.value.trim();
linkDescInput.value = linkDescInput.value.trim();
console.log(linkNameInput.value);
if (linkNameInput.value === "") {
return;
}
let formData = new FormData();
if (linkNameInput.value !== currentlyEditingLink.originalText) {
formData.append("name", linkNameInput.value)
}
if (linkDescInput.value !== currentlyEditingLink.originalDescription) {
formData.append("description", linkDescInput.value)
}
if (iconUploader.files.length > 0) {
formData.append("icon", iconUploader.files[0]);
}
// nothing to update
if (formData.get("name") === null && formData.get("description") === null && formData.get("icon") === null) {
return;
}
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
method: "PATCH",
body: formData
}); });
if (res.status === 200) {
iconUploader.value = "";
currentlyEditingLink.icon = null;
cancelLinkEdit(currentlyEditingLink.ID, currentlyEditingCategory.categoryID, linkNameInput.value || currentlyEditingLink.originalText, linkDescInput.value || currentlyEditingLink.originalDescription);
currentlyEditingLink.originalText = null;
currentlyEditingLink.originalDescription = null;
currentlyEditingLink.ID = null;
} else {
console.error("Failed to edit category");
}
}
async function cancelLinkEdit(linkID, categoryID, text = undefined, description = undefined) {
let linkEl = document.querySelector(`[key=link-${linkID}]`);
let linkInput = linkEl.querySelector("input");
let linkTextarea = linkEl.querySelector("textarea");
let linkImg = linkEl.querySelector("div:first-child img");
let fileUploaderOverlay = linkImg.nextElementSibling;
let editActions = linkEl.querySelector("[data-edit-actions]");
console.log(linkInput);
console.log(editActions);
if (currentlyEditingLink.icon !== null) {
linkImg.src = currentlyEditingLink.icon;
}
editActions.querySelector("div").classList.remove("hidden");
editActions.querySelector("div").classList.add("flex");
editActions.querySelector("div:nth-child(2)").classList.add("hidden");
editActions.querySelector("div:nth-child(2)").classList.remove("flex");
fileUploaderOverlay.classList.add("hidden");
fileUploaderOverlay.classList.remove("flex");
if (text === undefined) {
text = currentlyEditingLink.originalText;
}
if (description === undefined) {
description = currentlyEditingLink.originalDescription;
}
restoreElementFromInput(linkInput, text);
restoreElementFromInput(linkTextarea, description);
currentlyEditingLink.ID = null;
targetedImageElement = null;
}
let currentlyDeletingLink = {
ID: null,
categoryID: null,
};
async function deleteLink(linkID, categoryID) { async function deleteLink(linkID, categoryID) {
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, { currentlyDeletingLink.ID = linkID;
currentlyDeletingLink.categoryID = categoryID;
let linkNameSpan = document.getElementById("link-name");
linkNameSpan.textContent = document.querySelector(`[key=link-${linkID}] h3`).textContent;
openModal("link-delete");
}
async function confirmDeleteLink() {
let res = await fetch(`/api/category/${currentlyDeletingLink.categoryID}/link/${currentlyDeletingLink.ID}`, {
method: "DELETE" method: "DELETE"
}); });
if (res.status === 200) { if (res.status === 200) {
let linkEl = document.querySelector(`[key="link-${linkID}"]`); let linkEl = document.querySelector(`[key="link-${currentlyDeletingLink.ID}"]`);
linkEl.remove(); linkEl.remove();
closeModal();
} }
} }
let currentlyEditingCategory = {
ID: null,
originalText: null,
icon: null,
};
async function editCategory(categoryID) {
if (currentlyEditingCategory.ID !== null) {
// cancel the edit if it's already in progress
cancelCategoryEdit(currentlyEditingCategory.ID);
}
currentlyEditingCategory.ID = categoryID;
let categoryEl = document.querySelector(`[key=category-${categoryID}]`);
let categoryName = categoryEl.querySelector("h2");
let categoryIcon = categoryEl.querySelector("div img");
let fileUploaderOverlay = categoryIcon.nextElementSibling;
let editActions = categoryEl.querySelector("[data-edit-actions]");
currentlyEditingCategory.originalText = categoryName.textContent;
currentlyEditingCategory.icon = categoryIcon.src;
iconUploader.accept = "image/svg+xml";
targetedImageElement = categoryIcon;
editActions.querySelector("div").classList.add("hidden");
editActions.querySelector("div").classList.remove("flex");
editActions.querySelector("div:nth-child(2)").classList.remove("hidden");
editActions.querySelector("div:nth-child(2)").classList.add("flex");
fileUploaderOverlay.classList.remove("hidden");
fileUploaderOverlay.classList.add("flex");
replaceWithResizableInput(categoryName);
}
async function confirmCategoryEdit(categoryID) {
let categoryEl = document.querySelector(`[key=category-${categoryID}]`);
let categoryInput = categoryEl.querySelector("input");
let categoryIcon = categoryEl.querySelector("div img");
let fileUploaderOverlay = categoryIcon.nextElementSibling;
let editActions = categoryEl.querySelector("[data-edit-actions]");
if (categoryInput.value === "") {
return;
}
categoryInput.value = categoryInput.value.trim();
let formData = new FormData();
if (categoryInput.value !== currentlyEditingCategory.originalText) {
formData.append("name", categoryInput.value)
}
if (iconUploader.files.length > 0) {
formData.append("icon", iconUploader.files[0]);
}
// nothing to update
if (formData.get("name") === null && formData.get("icon") === null) {
return;
}
let res = await fetch(`/api/category/${categoryID}`, {
method: "PATCH",
body: formData
});
if (res.status === 200) {
iconUploader.value = "";
cancelCategoryEdit(categoryID, categoryInput.value || currentlyEditingCategory.originalText);
currentlyEditingCategory.icon = null;
currentlyEditingCategory.originalText = null;
currentlyEditingCategory.ID = null;
} else {
console.error("Failed to edit category");
}
}
async function cancelCategoryEdit(categoryID, text = undefined) {
let categoryEl = document.querySelector(`[key=category-${categoryID}]`);
let categoryInput = categoryEl.querySelector("input");
let categoryIcon = categoryEl.querySelector("div img");
let fileUploaderOverlay = categoryIcon.nextElementSibling;
let editActions = categoryEl.querySelector("[data-edit-actions]");
console.log(categoryInput);
console.log(editActions);
if (currentlyEditingCategory.icon !== null) {
categoryIcon.src = currentlyEditingCategory.icon;
}
editActions.querySelector("div").classList.remove("hidden");
editActions.querySelector("div").classList.add("flex");
editActions.querySelector("div:nth-child(2)").classList.add("hidden");
editActions.querySelector("div:nth-child(2)").classList.remove("flex");
fileUploaderOverlay.classList.remove("flex");
fileUploaderOverlay.classList.add("hidden");
restoreElementFromInput(categoryInput, text || currentlyEditingCategory.originalText);
currentlyEditingCategory.ID = null;
targetedImageElement = null;
}
let currentlyDeletingCategory = {
ID: null,
};
async function deleteCategory(categoryID) { async function deleteCategory(categoryID) {
let res = await fetch(`/api/category/${categoryID}`, { currentlyDeletingCategory.ID = categoryID;
let categoryNameSpan = document.getElementById("category-name");
categoryNameSpan.textContent = document.querySelector(`[key=category-${categoryID}] h2`).textContent;
openModal("category-delete");
}
async function confirmDeleteCategory() {
let res = await fetch(`/api/category/${currentlyDeletingCategory.ID}`, {
method: "DELETE" method: "DELETE"
}); });
if (res.status === 200) { if (res.status === 200) {
let categoryEl = document.querySelector(`[key="category-${categoryID}"]`); let categoryEl = document.querySelector(`[key="category-${currentlyDeletingCategory.ID}"]`);
// get the next element and remove it (its the link grid) // get the next element and remove it (its the link grid)
let nextEl = categoryEl.nextElementSibling; let nextEl = categoryEl.nextElementSibling;
nextEl.remove(); nextEl.remove();
categoryEl.remove(); categoryEl.remove();
closeModal();
} }
} }
document.getElementById("link-form").querySelector("button").addEventListener("click", (event) => { /**
document.getElementById("link-form").querySelectorAll("[required]").forEach((el) => { * Replaces an H2 element with a resizable input field that matches its initial text and styling.
el.classList.add("invalid:border-[#861024]!"); * @param {HTMLElement} targetEl The element to replace.
}); */
function replaceWithResizableInput(targetEl) {
const originalText = targetEl.textContent;
const computedStyle = window.getComputedStyle(targetEl);
const inputElement = document.createElement('input');
inputElement.type = 'text';
inputElement.value = originalText;
inputElement.className = 'resizable-input';
inputElement.placeholder = 'Enter title...';
inputElement.dataset.originalElementType = targetEl.tagName;
inputElement.dataset.originalClassName = targetEl.className;
const stylesToCopy = [
'font-family', 'font-size', 'font-weight', 'font-style', 'color',
'line-height', 'letter-spacing', 'text-transform', 'text-align',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
'border-radius', 'box-sizing',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'height'
];
stylesToCopy.forEach(prop => {
inputElement.style[prop] = computedStyle[prop];
}); });
document.getElementById("link-form").addEventListener("submit", async (event) => { inputElement.style.display = 'inline-block';
event.preventDefault(); inputElement.style.backgroundColor = 'var(--color-base)';
let data = new FormData(event.target); inputElement.style.border = '1px solid var(--color-highlight-sm)';
inputElement.style.borderRadius = '0.375rem';
inputElement.maxLength = 50;
let res = await fetch(`/api/category/${targetCategoryID}/link`, { /**
method: "POST", * Function to measure the text width accurately and apply it to the input.
body: data * @param {HTMLInputElement} inputEl The input element to resize.
*/
const resizeInput = (inputEl) => {
const tempSpan = document.createElement('span');
const currentInputComputedStyle = window.getComputedStyle(inputEl);
const textStylesToCopy = [
'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing',
'text-transform', 'line-height'
];
textStylesToCopy.forEach(prop => {
tempSpan.style[prop] = currentInputComputedStyle[prop];
}); });
if (res.status === 201) { tempSpan.style.position = 'absolute';
closeModal('link'); tempSpan.style.visibility = 'hidden';
document.getElementById("link-form").reset(); tempSpan.style.whiteSpace = 'nowrap';
location.reload(); tempSpan.textContent = inputEl.value === '' ? inputEl.placeholder || 'W' : inputEl.value;
document.body.appendChild(tempSpan);
let measuredTextWidth = tempSpan.offsetWidth;
document.body.removeChild(tempSpan);
// Add a small buffer for the caret and a bit of extra space
const caretBuffer = 10;
let finalWidth = measuredTextWidth + caretBuffer;
const minWidth = 100;
finalWidth = Math.max(finalWidth, minWidth);
if (currentInputComputedStyle.boxSizing === 'border-box') {
const hPadding = parseFloat(currentInputComputedStyle.paddingLeft) + parseFloat(currentInputComputedStyle.paddingRight);
const hBorder = parseFloat(currentInputComputedStyle.borderLeftWidth) + parseFloat(currentInputComputedStyle.borderRightWidth);
inputEl.style.width = (finalWidth + hPadding + hBorder) + 'px';
} else { } else {
let json = await res.json(); inputEl.style.width = finalWidth + 'px';
document.getElementById("category-message").innerText = json.message;
} }
}); };
document.getElementById("category-form").querySelector("button").addEventListener("click", (event) => { setTimeout(() => resizeInput(inputElement), 0);
document.getElementById("category-form").querySelectorAll("[required]").forEach((el) => { inputElement.addEventListener('input', () => resizeInput(inputElement));
el.classList.add("invalid:border-[#861024]!"); targetEl.parentNode.replaceChild(inputElement, targetEl);
});
});
document.getElementById("category-form").addEventListener("submit", async (event) => { inputElement.focus();
event.preventDefault();
let data = new FormData(event.target);
let res = await fetch(`/api/category`, {
method: "POST",
body: data
});
if (res.status === 201) {
closeModal('category');
document.getElementById("category-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("link-message").innerText = json.message;
} }
function replaceWithResizableTextarea(targetEl) {
const originalText = targetEl.textContent;
const computedStyle = window.getComputedStyle(targetEl);
const inputElement = document.createElement('textarea');
inputElement.value = originalText;
inputElement.className = 'resizable-input';
inputElement.placeholder = 'Enter title...';
inputElement.dataset.originalElementType = targetEl.tagName;
inputElement.dataset.originalClassName = targetEl.className;
const stylesToCopy = [
'font-family', 'font-size', 'font-weight', 'font-style', 'color',
'line-height', 'letter-spacing', 'text-transform', 'text-align',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
'border-radius', 'box-sizing',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'height'
];
stylesToCopy.forEach(prop => {
inputElement.style[prop] = computedStyle[prop];
}); });
inputElement.style.backgroundColor = 'var(--color-base)';
inputElement.style.border = '1px solid var(--color-highlight-sm)';
inputElement.style.borderRadius = '0.375rem';
inputElement.style.resize = 'none';
inputElement.style.overflow = 'hidden';
inputElement.style.width = '100%';
inputElement.style.outline = 'none';
inputElement.maxLength = 150;
function resize() {
inputElement.style.height = "0px";
inputElement.style.height = inputElement.scrollHeight + "px";
}
setTimeout(() => resize(), 0);
inputElement.addEventListener('input', () => resize());
targetEl.parentNode.replaceChild(inputElement, targetEl);
inputElement.focus();
}
function restoreElementFromInput(inputEl, originalText) {
const computedStyle = window.getComputedStyle(inputEl);
let elementType = inputEl.dataset.originalElementType;
const newElement = document.createElement(elementType);
newElement.textContent = originalText;
newElement.className = inputEl.dataset.originalClassName;
newElement.style.border = '1px solid #0000';
const stylesToCopy = [
'font-family', 'font-size', 'font-weight', 'font-style', 'color',
'line-height', 'letter-spacing', 'text-transform', 'text-align',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'height'
];
stylesToCopy.forEach(prop => {
newElement.style[prop] = computedStyle[prop];
});
inputEl.parentNode.replaceChild(newElement, inputEl);
}
</script> </script>
<style> <style>

View File

@@ -83,10 +83,12 @@
<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">
<div>
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" /> <img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
</div>
<div> <div>
<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

@@ -13,6 +13,6 @@
"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"
}, },
"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"
} }