4 Commits

Author SHA1 Message Date
Zoe
f6ffc90ec2 V0.3.2: Improved admin UI and performance galore
Some checks failed
Build and Push Docker Image to GHCR / build-and-push (push) Failing after 21m16s
This commit fixes a plethora of bugs related to the admin UI, as well as
dramatically improving the performance of in-place editing. Furthermore,
several server bugs and misc bugs have been fixed. The admin UI is now
entirely client side when adding, deleting, or editng a category or
link. Other internal improvements hasve also been made.
2025-10-02 00:16:53 -05:00
Zoe
75fe60b4c9 arm64 docker image builds
Some checks failed
Build and Push Docker Image to GHCR / build-and-push (push) Failing after 20m31s
2025-10-01 00:27:45 -05:00
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
20 changed files with 2077 additions and 429 deletions

View File

@@ -27,6 +27,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ env.OCI_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
@@ -40,3 +47,4 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64

19
.prettierrc Normal file
View File

@@ -0,0 +1,19 @@
{
"tabWidth": 4,
"useTabs": false,
"singleQuote": false,
"semi": true,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"embeddedLanguageFormatting": "auto",
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false
}

View File

@@ -3,21 +3,33 @@ FROM golang:1.25 AS builder
# build dependencies
RUN apt update && apt install -y upx
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-x64
RUN chmod +x tailwindcss-linux-x64
RUN mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
ARG TARGETARCH
RUN set -eux; \
echo "Building for architecture: ${TARGETARCH}"; \
case "${TARGETARCH}" in \
"amd64") \
arch_suffix='x64' ;; \
"arm64") \
arch_suffix='arm64' ;; \
*) \
echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \
esac; \
curl -sLO "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-${arch_suffix}"; \
mv "tailwindcss-linux-${arch_suffix}" /usr/local/bin/tailwindcss; \
chmod +x /usr/local/bin/tailwindcss;
RUN go install github.com/juls0730/zqdgr@latest
WORKDIR /app
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
ARG TARGETARCH
ENV CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH}
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN zqdgr build
RUN upx passport

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_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
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.

5
go.mod
View File

@@ -4,7 +4,10 @@ go 1.25.0
require (
github.com/HugoSmits86/nativewebp v1.2.0
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta
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
modernc.org/sqlite v1.39.0
)
@@ -17,6 +20,7 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tinylib/msgp v1.4.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
@@ -42,6 +46,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/tdewolff/minify/v2 v2.24.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.66.0 // indirect
golang.org/x/sys v0.36.0 // indirect

14
go.sum
View File

@@ -1,5 +1,7 @@
github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4=
github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8=
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta h1:tNj6Rq9S3UUnF2800h6Ns7wmx+q7MwoZBVD24fPCSlo=
github.com/NarmadaWeb/gonify/v3 v3.0.0-beta/go.mod h1:AoLhZCGC/9XGqOE+0amArp/dFIZSfZSvbyPI/IbQ7Q0=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
@@ -7,6 +9,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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
@@ -51,6 +55,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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
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/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
@@ -60,6 +66,12 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA=
github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -74,6 +86,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/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/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/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
@@ -86,6 +99,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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
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/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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
@@ -26,8 +26,11 @@ import (
"time"
"github.com/HugoSmits86/nativewebp"
"github.com/NarmadaWeb/gonify/v3"
"github.com/caarlos0/env/v11"
"github.com/disintegration/imaging"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/compress"
"github.com/gofiber/fiber/v3/middleware/helmet"
"github.com/gofiber/fiber/v3/middleware/static"
"github.com/gofiber/template/handlebars/v2"
@@ -35,31 +38,46 @@ import (
"github.com/joho/godotenv"
"github.com/juls0730/passport/src/middleware"
"github.com/juls0730/passport/src/services"
"github.com/rwcarlsen/goexif/exif"
"github.com/rwcarlsen/goexif/tiff"
"golang.org/x/image/draw"
_ "modernc.org/sqlite"
)
//go:embed assets/** templates/** schema.sql
//go:embed assets/** templates/** schema.sql scripts/**.js styles/**.css
var embeddedAssets embed.FS
var devContent = `<script>
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) => {
if (event.data === 'refresh') {
console.log('Got refresh signal');
let attempts = 0;
let delay = 100;
async function testPage() {
try {
let res = await fetch(window.location.href)
} catch (error) {
console.error(error);
setTimeout(testPage, 300);
if (attempts > 5) {
return;
}
setTimeout(testPage, delay);
// exponential backoff
attempts++;
delay = 100 * Math.pow(2, attempts);
return;
}
window.location.reload();
}
testPage();
setTimeout(testPage, 150);
}
});
</script>`
@@ -261,7 +279,14 @@ func CropToCenter(img image.Image, outputSize int) (image.Image, error) {
return outputImg, nil
}
func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fiber.Ctx) (string, error) {
func UploadFile(file *multipart.FileHeader, contentType string, c fiber.Ctx) (string, error) {
fileId, err := uuid.NewV7()
if err != nil {
return "", err
}
fileName := fmt.Sprintf("%s.%s", fileId.String(), filepath.Ext(file.Filename))
srcFile, err := file.Open()
if err != nil {
return "", err
@@ -282,16 +307,67 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
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 {
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"
iconPath := filepath.Join(assetsDir, fileName)
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
}
} else {
@@ -301,16 +377,9 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
}
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
options := &nativewebp.Options{}
if err := nativewebp.Encode(&buf, resizedImg, options); err != nil {
if err := nativewebp.Encode(&buf, img, options); err != nil {
return "", err
}
@@ -637,16 +706,23 @@ func main() {
log.Fatal(err)
}
css, err := fs.ReadFile(embeddedAssets, "assets/tailwind.css")
if err != nil {
log.Fatal(err)
}
engine := handlebars.NewFileSystem(http.FS(templatesDir), ".hbs")
engine.AddFunc("inlineCSS", func() string {
return string(css)
engine.AddFunc("embedFile", func(fileToEmbed string) string {
content, err := fs.ReadFile(embeddedAssets, fileToEmbed)
if err != nil {
return ""
}
fileExtension := filepath.Ext(fileToEmbed)
switch fileExtension {
case ".js":
return fmt.Sprintf("<script>%s</script>", content)
case ".css":
return fmt.Sprintf("<style>%s</style>", content)
default:
return string(content)
}
})
engine.AddFunc("devContent", func() string {
@@ -656,10 +732,6 @@ func main() {
return ""
})
engine.AddFunc("eq", func(a, b any) bool {
return a == b
})
router := fiber.New(fiber.Config{
Views: engine,
})
@@ -671,6 +743,17 @@ func main() {
return c.Redirect().To("/assets/favicon.ico")
})
router.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
}))
router.Use(gonify.New(gonify.Config{
MinifySVG: !app.DevMode,
MinifyCSS: !app.DevMode,
MinifyJS: !app.DevMode,
MinifyHTML: !app.DevMode,
}))
router.Use("/", static.New("./public", static.Config{
Browse: false,
MaxAge: 31536000,
@@ -761,7 +844,7 @@ func main() {
return c.Render("views/admin/index", fiber.Map{
"Categories": app.CategoryManager.GetCategories(),
}, "layouts/main")
}, "layouts/admin")
})
api := router.Group("/api")
@@ -790,6 +873,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")
if err != nil || file == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -810,17 +901,13 @@ func main() {
})
}
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
iconPath, err := UploadFile(file, filename, contentType, c)
iconPath, err := UploadFile(file, contentType, c)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to upload file, please try again!",
})
}
UploadFile(file, iconPath, contentType, c)
category, err := app.CategoryManager.CreateCategory(Category{
Name: req.Name,
Icon: iconPath,
@@ -857,6 +944,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)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -890,9 +994,7 @@ func main() {
})
}
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
iconPath, err := UploadFile(file, filename, contentType, c)
iconPath, err := UploadFile(file, contentType, c)
if err != nil {
slog.Error("Failed to upload file", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -900,8 +1002,6 @@ func main() {
})
}
UploadFile(file, iconPath, contentType, c)
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
CategoryID: categoryID,
Name: req.Name,
@@ -922,6 +1022,241 @@ 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
iconPath, err := UploadFile(file, 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
iconPath, err := UploadFile(file, 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 {
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
if err != nil {

1106
src/scripts/admin.js Normal file

File diff suppressed because it is too large Load Diff

70
src/styles/adminUi.css Normal file
View File

@@ -0,0 +1,70 @@
.modal-bg {
visibility: hidden;
opacity: 0;
}
.modal-bg.is-visible {
visibility: visible;
opacity: 1;
}
.modal {
opacity: 0;
}
.modal.is-visible {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
.modal-bg {
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease, visibility 0s 0.3s;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
}
.modal-bg.is-visible {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.modal {
opacity: 0;
transform: translateY(20px) scale(0.95);
transition: opacity 0.3s ease, transform 0.3s ease;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
}
.modal.is-visible {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
transition-delay: 0s;
}
}
.action-button {
display: flex;
width: fit-content;
height: fit-content;
padding: 0.25rem;
background-color: var(--color-highlight-sm);
border: 1px solid color-mix(in srgb, var(--color-highlight) 70%, #0000);
border-radius: 9999px;
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: filter 0.15s cubic-bezier(0.45, 0, 0.55, 1);
contain: layout style paint;
&:hover {
filter: brightness(125%);
}
&:active {
filter: brightness(95%);
}
}

View File

@@ -1,127 +0,0 @@
@import "tailwindcss";
@theme {
--color-accent: oklch(57.93% 0.258 294.12);
--color-success: oklch(70.19% 0.158 160.44);
--color-error: oklch(63.43% 0.251 28.48);
--color-base: oklch(11% .007 285);
--color-surface: oklch(19% 0.007 314.66);
--color-overlay: oklch(26% 0.008 314.66);
--color-muted: oklch(63% 0.015 286);
--color-subtle: oklch(72% 0.015 286);
--color-text: oklch(87% 0.015 286);
--color-highlight-sm: oklch(30.67% 0.007 286);
--color-highlight: oklch(39.26% 0.010 286);
--color-highlight-lg: oklch(47.72% 0.011 286);
}
@font-face {
font-family: "Instrument Sans";
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2");
font-display: swap;
}
:root {
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
html {
line-height: normal;
color-scheme: dark;
color: var(--color-text);
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-semibold;
}
h1 {
font-size: clamp(42px, 10vw, 64px);
}
h2 {
font-size: clamp(30px, 6vw, 36px);
}
h3 {
font-size: 1.25rem;
}
button {
cursor: pointer;
}
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;
&[type="file"] {
@apply p-0 cursor-pointer;
&::file-selector-button {
@apply px-2 py-2 mr-1 bg-highlight text-subtle cursor-pointer;
}
}
}
.link-card {
background: var(--color-overlay);
display: flex;
flex-direction: row;
text-decoration: none;
border-radius: 1rem;
padding: 0.625rem;
align-items: center;
transition-property: box-shadow, transform, translate;
transition-duration: 150ms;
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);
&:hover {
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 {
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
transform: translateY(2px);
}
}
@media (prefers-reduced-motion: reduce) {
.link-card {
transition: none;
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
}
.link-card img {
margin-right: 0.5rem;
user-select: none;
border-radius: 0.375rem;
aspect-ratio: 1/1;
object-fit: cover;
}
.link-card div {
word-break: break-all;
}
.link-card div p {
color: var(--color-subtle);
}

195
src/styles/main.scss Normal file
View File

@@ -0,0 +1,195 @@
@import "tailwindcss";
@theme {
--color-accent: oklch(57.93% 0.258 294.12);
--color-success: oklch(70.19% 0.158 160.44);
--color-error: oklch(53% 0.251 28.48);
--color-base: oklch(11% 0.007 285);
--color-surface: oklch(19% 0.007 285.66);
--color-overlay: oklch(26% 0.008 285.66);
--color-muted: oklch(63% 0.015 286);
--color-subtle: oklch(72% 0.015 286);
--color-text: oklch(87% 0.015 286);
--color-highlight-sm: oklch(30.67% 0.007 286);
--color-highlight: oklch(39.26% 0.01 286);
--color-highlight-lg: oklch(47.72% 0.011 286);
}
@font-face {
font-family: "Instrument Sans";
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2")
format("woff2");
font-display: swap;
}
:root {
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
}
html {
line-height: normal;
color-scheme: dark;
color: var(--color-text);
}
@layer base {
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
}
h1 {
font-size: clamp(42px, 10vw, 64px);
}
h2 {
font-size: clamp(30px, 6vw, 36px);
}
h3 {
font-size: 1.25rem;
}
}
button {
cursor: pointer;
}
input:not(.search) {
@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"] {
@apply p-0 cursor-pointer;
&::file-selector-button {
@apply px-2 py-2 mr-1 bg-highlight text-subtle cursor-pointer;
}
}
}
.link-card {
background: var(--color-overlay);
display: flex;
flex-direction: row;
text-decoration: none;
border-radius: 1rem;
padding: 0.625rem;
align-items: center;
transition-property: box-shadow, transform, translate;
transition-duration: 150ms;
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);
contain: layout style paint;
&:not(.admin) {
&:hover {
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 {
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
transform: translateY(2px);
}
}
}
@media (prefers-reduced-motion: reduce) {
.link-card {
transition: none;
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
}
/* Div that holds the image */
.link-card div[data-img-container] {
flex-shrink: 0;
padding-right: 0.5rem;
}
.link-card div[data-img-container] img {
user-select: none;
border-radius: 0.375rem;
aspect-ratio: 1/1;
object-fit: cover;
}
/* Div that holds the text */
.link-card div[data-text-container] {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 1px;
overflow: hidden;
word-break: break-all;
}
.link-card div[data-text-container] p {
color: var(--color-subtle);
white-space: pre-wrap;
border: 1px solid #0000;
min-height: 22px;
}
.new-link-card {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.625rem;
border: 0.125rem dashed var(--color-subtle);
border-radius: 1rem;
transition: box-shadow, transofrm 150ms cubic-bezier(0.45, 0, 0.55, 1);
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
}
.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;
}
.link-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr));
gap: 0.5rem;
padding: 0.625rem;
contain: layout style paint;
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Passport</title>
<link rel="favicon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" />
{{{embedFile "assets/tailwind.css"}}}
{{{embedFile "styles/adminUi.css"}}}
</head>
<body class="bg-surface text-text">
{{embed}}
</body>
{{{devContent}}}
</html>

View File

@@ -3,14 +3,17 @@
<head>
<title>Passport</title>
<link rel="favicon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous" href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2">
<style>{{{inlineCSS}}}</style>
<link rel="favicon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" />
{{{embedFile "assets/tailwind.css"}}}
</head>
<body class="bg-surface text-text">
{{embed}}
</body>
{{{devContent}}}
</html>

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

@@ -17,37 +17,76 @@
<section class="flex justify-center w-full">
<div class="w-full sm:w-4/5 p-2.5">
{{#each Categories}}
<div class="flex items-center" key="category-{{this.ID}}">
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false"
alt="{{this.Name}}" src="{{this.Icon}}" />
<h2 class="capitalize break-all">{{this.Name}}</h2>
<button onclick="deleteCategory({{this.ID}})"
class="w-fit h-fit flex p-0.5 bg-base border border-highlight rounded-md hover:filter hover:brightness-125 cursor-pointer"><svg
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"
<div class="flex items-center category-header" id="{{this.ID}}_category">
<div class="category-img" data-img-container>
<img width="32" height="32" draggable="false" alt="{{this.Name}}" src="{{this.Icon}}" />
</div>
<h2 data-placeholder="Enter title...">{{~ this.Name ~}}</h2>
<div class="pl-2" data-edit-actions>
<div class="flex flex-row gap-2" data-primary-actions>
<button aria-label="Edit category" onclick="editCategory(this)" class="action-button">
<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)"
class="text-error action-button">
<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" />
</svg></button>
</svg>
</button>
</div>
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
</div>
</div>
<div class="link-grid">
{{#each this.Links}}
<div key="link-{{this.ID}}" class="link-card relative">
<div id="{{this.ID}}_link" class="link-card relative admin">
<div class="relative" data-img-container>
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
<div>
<h3>{{this.Name}}</h3>
<p>{{this.Description}}</p>
</div>
<button onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
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
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"
<div data-text-container>
<h3 class="border border-transparent" data-placeholder="Enter title...">
{{~ this.Name ~}}
</h3>
<!-- add 2 to the height to account for the border -->
<p data-placeholder="Enter description...">
{{~ this.Description ~}}
</p>
</div>
<div class="absolute right-1 top-1" data-edit-actions>
<div class="flex flex-row gap-2" data-primary-actions>
<button aria-label="Edit link" onclick="editLink(this)" class="action-button">
<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)"
class="text-error action-button">
<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" />
</svg></button>
</svg>
</button>
</div>
</div>
</div>
{{/each}}
<div onclick="openModal('link', {{this.ID}})"
class="rounded-2xl border border-dashed border-subtle p-2.5 flex flex-row items-center hover:underline transition-[box-shadow,transform] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 pointer-cursor select-none cursor-pointer">
<div onclick="openModal('link', {{this.ID}})" class="new-link-card">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m-7-7h14" />
@@ -58,7 +97,7 @@
</div>
</div>
{{/each}}
<div class="flex items-center">
<div class="flex items-center" id="add-category-button">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m-7-7h14" />
@@ -71,222 +110,99 @@
</section>
</div>
<div id="modal-container"
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center">
<input type="file" id="icon-upload" accept="image/*" style="display: none;" />
<div id="modal-container" role="dialog" aria-modal="true"
class="modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center hidden">
<div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm">
<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" />
</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>
{{> 'partials/modals/category-form' }}
{{> 'partials/modals/link-form' }}
{{> 'partials/modals/delete-link' }}
{{> 'partials/modals/delete-category' }}
</div>
</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" />
<!-- store a blank link card so that if we add a new link we can clone it to make the editing experience easier -->
<div id="template-link-card" class="hidden">
<div class="relative" data-img-container>
<img width="64" height="64" draggable="false" />
</div>
<div>
<label for="linkDesc">Description (optional)</label>
<input type="text" name="description" id="linkDesc" />
<div class="flex-grow flex flex-col gap-y-px overflow-hidden" data-text-container>
<h3 class="border border-transparent"></h3>
<!-- add 2 to the height to account for the border -->
<p class="min-h-[22px] border border-transparent"></p>
</div>
<div>
<label for="linkURL">URL</label>
<input required type="url" name="url" id="linkURL" />
</div>
<div id="template-category" class="hidden">
<div class="flex items-center category-header">
<div class="category-img" data-img-container>
<img width="32" height="32" draggable="false" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input required type="file" name="icon" id="linkIcon" accept="image/*" />
<h2></h2>
</div>
<div class="link-grid">
<div class="new-link-card">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 5v14m-7-7h14" />
</svg>
<div>
<h3>Add a link</h3>
</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>
<script>
// idfk what this variable capitalization is, it's a mess
let modalContainer = document.getElementById("modal-container");
let modal = modalContainer.querySelector("div");
let pageElement = document.getElementById("blur-target");
let targetCategoryID = null;
let activeModal = null;
<div id="template-edit-actions" class="hidden" data-edit-actions>
<div class="flex flex-row gap-2" data-primary-actions>
<button class="action-button">
<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 class="text-error action-button">
<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" />
</svg>
</button>
</div>
</div>
function openModal(modalKind, categoryID) {
activeModal = modalKind;
targetCategoryID = categoryID;
<div id="teleport-storage" class="absolute -top-full -left-full hidden">
<!-- These are the elements that appear when the user enters edit mode, they allow for the cancelation/confirmation of the edit -->
<div class="flex flex-row gap-2" data-confirm-actions id="confirm-actions">
<button class="action-button text-success" onclick="confirmEdit()">
<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 class="action-button text-error" onclick="cancelEdit()">
<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>
pageElement.style.filter = "blur(20px)";
document.getElementById(modalKind + "-contents").classList.remove("hidden");
<!-- This is the element that appears on top of the icon when the user is editing it that allows for changing the icon -->
<button id="select-icon-button" onclick="selectIcon()"
class="flex absolute inset-0 bg-highlight/80 rounded-md text-base items-center justify-center"
draggable="false">
<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>
modalContainer.classList.add("is-visible");
modal.classList.add("is-visible");
document.getElementById(modalKind + "-form").reset();
}
function closeModal() {
pageElement.style.filter = "";
modalContainer.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => {
document.getElementById(activeModal + "-contents").classList.add("hidden");
activeModal = null;
}, 300)
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
el.classList.remove("invalid:border-[#861024]!");
});
targetCategoryID = null;
}
modalContainer.addEventListener("click", (event) => {
if (event.target === modalContainer) {
closeModal();
}
});
async function deleteLink(linkID, categoryID) {
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
method: "DELETE"
});
if (res.status === 200) {
let linkEl = document.querySelector(`[key="link-${linkID}"]`);
linkEl.remove();
}
}
async function deleteCategory(categoryID) {
let res = await fetch(`/api/category/${categoryID}`, {
method: "DELETE"
});
if (res.status === 200) {
let categoryEl = document.querySelector(`[key="category-${categoryID}"]`);
// get the next element and remove it (its the link grid)
let nextEl = categoryEl.nextElementSibling;
nextEl.remove();
categoryEl.remove();
}
}
document.getElementById("link-form").querySelector("button").addEventListener("click", (event) => {
document.getElementById("link-form").querySelectorAll("[required]").forEach((el) => {
el.classList.add("invalid:border-[#861024]!");
});
});
document.getElementById("link-form").addEventListener("submit", async (event) => {
event.preventDefault();
let data = new FormData(event.target);
let res = await fetch(`/api/category/${targetCategoryID}/link`, {
method: "POST",
body: data
});
if (res.status === 201) {
closeModal('link');
document.getElementById("link-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("category-message").innerText = json.message;
}
});
document.getElementById("category-form").querySelector("button").addEventListener("click", (event) => {
document.getElementById("category-form").querySelectorAll("[required]").forEach((el) => {
el.classList.add("invalid:border-[#861024]!");
});
});
document.getElementById("category-form").addEventListener("submit", async (event) => {
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;
}
});
</script>
<style>
.modal-bg {
visibility: hidden;
opacity: 0;
}
.modal-bg.is-visible {
visibility: visible;
opacity: 1;
}
.modal {
opacity: 0;
}
.modal.is-visible {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
.modal-bg {
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease, visibility 0s 0.3s;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
}
.modal-bg.is-visible {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.modal {
opacity: 0;
transform: translateY(20px) scale(0.95);
transition: opacity 0.3s ease, transform 0.3s ease;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
}
.modal.is-visible {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
transition-delay: 0s;
}
}
</style>
{{{embedFile "scripts/admin.js"}}}

View File

@@ -76,17 +76,19 @@
<div class="w-full sm:w-4/5 p-2.5">
{{#each Categories}}
<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}}"
src="{{this.Icon}}" />
<img class="object-contain mr-2 select-none size-8" width="32" height="32" draggable="false"
alt="{{this.Name}}" src="{{this.Icon}}" />
<h2 class="capitalize break-all">{{this.Name}}</h2>
</div>
<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"
rel="noopener noreferrer">
<div class="link-grid">
{{#each this.Links}}
<a href="{{this.URL}}" class="link-card" draggable="false" target="_blank" rel="noopener noreferrer">
<div data-img-container>
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
<div>
</div>
<div data-text-container>
<h3>{{this.Name}}</h3>
<p>{{this.Description}}</p>
<p class="min-h-5">{{this.Description}}</p>
</div>
</a>
{{else}}

View File

@@ -1,6 +1,6 @@
{
"name": "passport",
"version": "0.2.0",
"version": "0.3.2",
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
"author": "juls0730",
"license": "BSL-1.0",
@@ -11,8 +11,8 @@
},
"scripts": {
"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,js,css,scss,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
"shutdown_signal": "SIGINT"
}