Compare commits
1 Commits
v0.3.2
...
55e132d80b
| Author | SHA1 | Date | |
|---|---|---|---|
|
55e132d80b
|
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
@@ -27,13 +27,6 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ env.OCI_TOKEN }}
|
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
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
@@ -47,4 +40,3 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
|
|||||||
19
.prettierrc
19
.prettierrc
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
22
Dockerfile
22
Dockerfile
@@ -3,33 +3,21 @@ FROM golang:1.25 AS builder
|
|||||||
# build dependencies
|
# build dependencies
|
||||||
RUN apt update && apt install -y upx
|
RUN apt update && apt install -y upx
|
||||||
|
|
||||||
ARG TARGETARCH
|
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-x64
|
||||||
RUN set -eux; \
|
RUN chmod +x tailwindcss-linux-x64
|
||||||
echo "Building for architecture: ${TARGETARCH}"; \
|
RUN mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
||||||
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
|
RUN go install github.com/juls0730/zqdgr@latest
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG TARGETARCH
|
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
|
||||||
ENV CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH}
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
||||||
RUN zqdgr build
|
RUN zqdgr build
|
||||||
RUN upx passport
|
RUN upx passport
|
||||||
|
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -4,7 +4,6 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/HugoSmits86/nativewebp v1.2.0
|
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/caarlos0/env/v11 v11.3.1
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||||
@@ -20,7 +19,6 @@ require (
|
|||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/sirupsen/logrus v1.8.1 // 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
|
github.com/tinylib/msgp v1.4.0 // indirect
|
||||||
golang.org/x/crypto v0.42.0 // indirect
|
golang.org/x/crypto v0.42.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
@@ -46,7 +44,6 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
github.com/rivo/uniseg v0.2.0 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.66.0 // indirect
|
github.com/valyala/fasthttp v1.66.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -1,7 +1,5 @@
|
|||||||
github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4=
|
github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4=
|
||||||
github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8=
|
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 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||||
@@ -66,12 +64,6 @@ 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.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 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
|
||||||
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
|
||||||
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
|||||||
101
src/main.go
101
src/main.go
@@ -26,11 +26,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/HugoSmits86/nativewebp"
|
"github.com/HugoSmits86/nativewebp"
|
||||||
"github.com/NarmadaWeb/gonify/v3"
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gofiber/fiber/v3"
|
"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/helmet"
|
||||||
"github.com/gofiber/fiber/v3/middleware/static"
|
"github.com/gofiber/fiber/v3/middleware/static"
|
||||||
"github.com/gofiber/template/handlebars/v2"
|
"github.com/gofiber/template/handlebars/v2"
|
||||||
@@ -44,7 +42,7 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/** templates/** schema.sql scripts/**.js styles/**.css
|
//go:embed assets/** templates/** schema.sql
|
||||||
var embeddedAssets embed.FS
|
var embeddedAssets embed.FS
|
||||||
|
|
||||||
var devContent = `<script>
|
var devContent = `<script>
|
||||||
@@ -279,14 +277,7 @@ func CropToCenter(img image.Image, outputSize int) (image.Image, error) {
|
|||||||
return outputImg, nil
|
return outputImg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func UploadFile(file *multipart.FileHeader, contentType string, c fiber.Ctx) (string, error) {
|
func UploadFile(file *multipart.FileHeader, fileName, 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()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -321,23 +312,25 @@ func UploadFile(file *multipart.FileHeader, contentType string, c fiber.Ctx) (st
|
|||||||
// if there *is* exif, parse it
|
// if there *is* exif, parse it
|
||||||
if err == nil {
|
if err == nil {
|
||||||
tag, err := x.Get(exif.Orientation)
|
tag, err := x.Get(exif.Orientation)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
if tag.Count == 1 && tag.Format() == tiff.IntVal {
|
return "", fmt.Errorf("failed to get orientation: %v", err)
|
||||||
orientation, err := tag.Int(0)
|
}
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get orientation: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Debug("Orientation tag found", "orientation", orientation)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
switch orientation {
|
slog.Debug("Orientation tag found", "orientation", orientation)
|
||||||
case 3:
|
|
||||||
img = imaging.Rotate180(img)
|
switch orientation {
|
||||||
case 6:
|
case 3:
|
||||||
img = imaging.Rotate270(img)
|
img = imaging.Rotate180(img)
|
||||||
case 8:
|
case 6:
|
||||||
img = imaging.Rotate90(img)
|
img = imaging.Rotate270(img)
|
||||||
}
|
case 8:
|
||||||
|
img = imaging.Rotate90(img)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -706,23 +699,16 @@ func main() {
|
|||||||
log.Fatal(err)
|
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 := handlebars.NewFileSystem(http.FS(templatesDir), ".hbs")
|
||||||
|
|
||||||
engine.AddFunc("embedFile", func(fileToEmbed string) string {
|
engine.AddFunc("inlineCSS", func() string {
|
||||||
content, err := fs.ReadFile(embeddedAssets, fileToEmbed)
|
return string(css)
|
||||||
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 {
|
engine.AddFunc("devContent", func() string {
|
||||||
@@ -732,6 +718,10 @@ func main() {
|
|||||||
return ""
|
return ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
engine.AddFunc("eq", func(a, b any) bool {
|
||||||
|
return a == b
|
||||||
|
})
|
||||||
|
|
||||||
router := fiber.New(fiber.Config{
|
router := fiber.New(fiber.Config{
|
||||||
Views: engine,
|
Views: engine,
|
||||||
})
|
})
|
||||||
@@ -743,17 +733,6 @@ func main() {
|
|||||||
return c.Redirect().To("/assets/favicon.ico")
|
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{
|
router.Use("/", static.New("./public", static.Config{
|
||||||
Browse: false,
|
Browse: false,
|
||||||
MaxAge: 31536000,
|
MaxAge: 31536000,
|
||||||
@@ -844,7 +823,7 @@ func main() {
|
|||||||
|
|
||||||
return c.Render("views/admin/index", fiber.Map{
|
return c.Render("views/admin/index", fiber.Map{
|
||||||
"Categories": app.CategoryManager.GetCategories(),
|
"Categories": app.CategoryManager.GetCategories(),
|
||||||
}, "layouts/admin")
|
}, "layouts/main")
|
||||||
})
|
})
|
||||||
|
|
||||||
api := router.Group("/api")
|
api := router.Group("/api")
|
||||||
@@ -901,7 +880,9 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
iconPath, err := UploadFile(file, contentType, c)
|
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 {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"message": "Failed to upload file, please try again!",
|
"message": "Failed to upload file, please try again!",
|
||||||
@@ -994,7 +975,9 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
iconPath, err := UploadFile(file, contentType, c)
|
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 {
|
if err != nil {
|
||||||
slog.Error("Failed to upload file", "error", err)
|
slog.Error("Failed to upload file", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
@@ -1086,7 +1069,9 @@ func main() {
|
|||||||
|
|
||||||
oldIconPath := category.Icon
|
oldIconPath := category.Icon
|
||||||
|
|
||||||
iconPath, err := UploadFile(file, contentType, c)
|
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 {
|
if err != nil {
|
||||||
slog.Error("Failed to upload file", "error", err)
|
slog.Error("Failed to upload file", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
@@ -1204,7 +1189,9 @@ func main() {
|
|||||||
|
|
||||||
oldIconPath := link.Icon
|
oldIconPath := link.Icon
|
||||||
|
|
||||||
iconPath, err := UploadFile(file, contentType, c)
|
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 {
|
if err != nil {
|
||||||
slog.Error("Failed to upload file", "error", err)
|
slog.Error("Failed to upload file", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
|||||||
1106
src/scripts/admin.js
1106
src/scripts/admin.js
File diff suppressed because it is too large
Load Diff
@@ -1,70 +0,0 @@
|
|||||||
.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%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|
||||||
@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(53% 0.251 28.48);
|
--color-error: oklch(53% 0.251 28.48);
|
||||||
|
|
||||||
--color-base: oklch(11% 0.007 285);
|
--color-base: oklch(11% .007 285);
|
||||||
--color-surface: oklch(19% 0.007 285.66);
|
--color-surface: oklch(19% 0.007 285.66);
|
||||||
--color-overlay: oklch(26% 0.008 285.66);
|
--color-overlay: oklch(26% 0.008 285.66);
|
||||||
|
|
||||||
@@ -14,21 +15,18 @@
|
|||||||
--color-text: oklch(87% 0.015 286);
|
--color-text: oklch(87% 0.015 286);
|
||||||
|
|
||||||
--color-highlight-sm: oklch(30.67% 0.007 286);
|
--color-highlight-sm: oklch(30.67% 0.007 286);
|
||||||
--color-highlight: oklch(39.26% 0.01 286);
|
--color-highlight: oklch(39.26% 0.010 286);
|
||||||
--color-highlight-lg: oklch(47.72% 0.011 286);
|
--color-highlight-lg: oklch(47.72% 0.011 286);
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Instrument Sans";
|
font-family: "Instrument Sans";
|
||||||
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2")
|
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2");
|
||||||
format("woff2");
|
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui,
|
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
|
||||||
"Noto Color Emoji";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -37,27 +35,25 @@ html {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
h1,
|
||||||
h1,
|
h2,
|
||||||
h2,
|
h3,
|
||||||
h3,
|
h4,
|
||||||
h4,
|
h5,
|
||||||
h5,
|
h6 {
|
||||||
h6 {
|
@apply font-semibold;
|
||||||
font-weight: 600;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: clamp(42px, 10vw, 64px);
|
font-size: clamp(42px, 10vw, 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: clamp(30px, 6vw, 36px);
|
font-size: clamp(30px, 6vw, 36px);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -88,12 +84,10 @@ input:not(.search) {
|
|||||||
transition-duration: 150ms;
|
transition-duration: 150ms;
|
||||||
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
contain: layout style paint;
|
|
||||||
|
|
||||||
&:not(.admin) {
|
&:not(.admin) {
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 10px 15px -3px 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);
|
||||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +113,12 @@ input:not(.search) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Div that holds the image */
|
/* Div that holds the image */
|
||||||
.link-card div[data-img-container] {
|
.link-card div:has(img):first-child {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-card div[data-img-container] img {
|
.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;
|
||||||
@@ -132,64 +126,10 @@ input:not(.search) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Div that holds the text */
|
/* Div that holds the text */
|
||||||
.link-card div[data-text-container] {
|
.link-card div:nth-child(2) {
|
||||||
display: flex;
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-card div[data-text-container] p {
|
.link-card div:nth-child(2) p {
|
||||||
color: var(--color-subtle);
|
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;
|
|
||||||
}
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -3,17 +3,14 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Passport</title>
|
<title>Passport</title>
|
||||||
<link rel="favicon" href="/favicon.ico" />
|
<link rel="favicon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
|
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous" href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2">
|
||||||
href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2" />
|
<style>{{{inlineCSS}}}</style>
|
||||||
{{{embedFile "assets/tailwind.css"}}}
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-surface text-text">
|
<body class="bg-surface text-text">
|
||||||
{{embed}}
|
{{embed}}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
{{{devContent}}}
|
{{{devContent}}}
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -17,14 +17,25 @@
|
|||||||
<section class="flex justify-center w-full">
|
<section class="flex justify-center w-full">
|
||||||
<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 category-header" id="{{this.ID}}_category">
|
<div class="flex items-center" key="category-{{this.ID}}">
|
||||||
<div class="category-img" data-img-container>
|
<div class="shrink-0 relative mr-2 h-full flex items-center justify-center">
|
||||||
<img width="32" height="32" draggable="false" alt="{{this.Name}}" src="{{this.Icon}}" />
|
<img class="object-contain select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
||||||
|
src="{{this.Icon}}" />
|
||||||
|
<button onclick="selectIcon()"
|
||||||
|
class="absolute inset-0 bg-highlight/80 hidden 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>
|
</div>
|
||||||
<h2 data-placeholder="Enter title...">{{~ this.Name ~}}</h2>
|
<h2 class="capitalize break-all border border-transparent">{{this.Name}}</h2>
|
||||||
<div class="pl-2" data-edit-actions>
|
<div class="ml-2" data-edit-actions>
|
||||||
<div class="flex flex-row gap-2" data-primary-actions>
|
<div class="flex flex-row gap-2">
|
||||||
<button aria-label="Edit category" onclick="editCategory(this)" class="action-button">
|
<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"
|
<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 -->
|
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"
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
@@ -34,8 +45,8 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button aria-label="Delete category" onclick="deleteCategory(this)"
|
<button aria-label="Delete category" onclick="deleteCategory({{this.ID}})"
|
||||||
class="text-error action-button">
|
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"
|
<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 -->
|
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"
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
@@ -44,26 +55,51 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="link-grid">
|
<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 id="{{this.ID}}_link" class="link-card relative admin">
|
<div key="link-{{this.ID}}" class="link-card relative admin">
|
||||||
<div class="relative" data-img-container>
|
<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}}" />
|
||||||
|
<button onclick="selectIcon()"
|
||||||
|
class="absolute inset-0 bg-highlight/80 hidden 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>
|
</div>
|
||||||
<div data-text-container>
|
<div class="flex-grow">
|
||||||
<h3 class="border border-transparent" data-placeholder="Enter title...">
|
<h3 class="border border-transparent">{{this.Name}}</h3>
|
||||||
{{~ this.Name ~}}
|
<p class="min-h-5">{{this.Description}}</p>
|
||||||
</h3>
|
|
||||||
<!-- add 2 to the height to account for the border -->
|
|
||||||
<p data-placeholder="Enter description...">
|
|
||||||
{{~ this.Description ~}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute right-1 top-1" data-edit-actions>
|
<div class="absolute right-1 top-1" data-edit-actions>
|
||||||
<div class="flex flex-row gap-2" data-primary-actions>
|
<div class="flex flex-row gap-2">
|
||||||
<button aria-label="Edit link" onclick="editLink(this)" class="action-button">
|
<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"
|
<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 -->
|
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"
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
@@ -73,8 +109,8 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button aria-label="Delete link" onclick="deleteLink(this)"
|
<button aria-label="Delete link" onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
|
||||||
class="text-error action-button">
|
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"
|
<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 -->
|
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"
|
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
@@ -83,10 +119,32 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
<div onclick="openModal('link', {{this.ID}})" class="new-link-card">
|
<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">
|
||||||
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
|
<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"
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
stroke-width="2" d="M12 5v14m-7-7h14" />
|
stroke-width="2" d="M12 5v14m-7-7h14" />
|
||||||
@@ -97,7 +155,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
<div class="flex items-center" id="add-category-button">
|
<div class="flex items-center">
|
||||||
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
|
<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"
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
stroke-width="2" d="M12 5v14m-7-7h14" />
|
stroke-width="2" d="M12 5v14m-7-7h14" />
|
||||||
@@ -112,7 +170,7 @@
|
|||||||
|
|
||||||
<input type="file" id="icon-upload" accept="image/*" style="display: none;" />
|
<input type="file" id="icon-upload" accept="image/*" style="display: none;" />
|
||||||
<div id="modal-container" role="dialog" aria-modal="true"
|
<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">
|
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">
|
||||||
{{> 'partials/modals/category-form' }}
|
{{> 'partials/modals/category-form' }}
|
||||||
{{> 'partials/modals/link-form' }}
|
{{> 'partials/modals/link-form' }}
|
||||||
@@ -121,88 +179,681 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- store a blank link card so that if we add a new link we can clone it to make the editing experience easier -->
|
<script>
|
||||||
<div id="template-link-card" class="hidden">
|
// idfk what this variable capitalization is, it's a mess
|
||||||
<div class="relative" data-img-container>
|
let modalContainer = document.getElementById("modal-container");
|
||||||
<img width="64" height="64" draggable="false" />
|
let modal = modalContainer.querySelector("div");
|
||||||
</div>
|
let pageElement = document.getElementById("blur-target");
|
||||||
<div class="flex-grow flex flex-col gap-y-px overflow-hidden" data-text-container>
|
let iconUploader = document.getElementById("icon-upload");
|
||||||
<h3 class="border border-transparent"></h3>
|
let targetCategoryID = null;
|
||||||
<!-- add 2 to the height to account for the border -->
|
let activeModal = null;
|
||||||
<p class="min-h-[22px] border border-transparent"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="template-category" class="hidden">
|
// errpr check the form and add the invalid class if it's invalid
|
||||||
<div class="flex items-center category-header">
|
|
||||||
<div class="category-img" data-img-container>
|
|
||||||
<img width="32" height="32" draggable="false" />
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="template-edit-actions" class="hidden" data-edit-actions>
|
/**
|
||||||
<div class="flex flex-row gap-2" data-primary-actions>
|
* Submits a form to the given URL
|
||||||
<button class="action-button">
|
* @param {Event} event - The event that triggered the function
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
* @param {string} url - The URL to submit the form to
|
||||||
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
* @param {"category" | "link"} target - The target to close the modal for
|
||||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
* @returns {Promise<void>}
|
||||||
<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" />
|
async function submitRequest(event, url, target) {
|
||||||
</g>
|
event.preventDefault();
|
||||||
</svg>
|
let data = new FormData(event.target);
|
||||||
</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>
|
|
||||||
|
|
||||||
<div id="teleport-storage" class="absolute -top-full -left-full hidden">
|
let res = await fetch(url, {
|
||||||
<!-- These are the elements that appear when the user enters edit mode, they allow for the cancelation/confirmation of the edit -->
|
method: "POST",
|
||||||
<div class="flex flex-row gap-2" data-confirm-actions id="confirm-actions">
|
body: data
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- This is the element that appears on top of the icon when the user is editing it that allows for changing the icon -->
|
if (res.status === 201) {
|
||||||
<button id="select-icon-button" onclick="selectIcon()"
|
closeModal(target);
|
||||||
class="flex absolute inset-0 bg-highlight/80 rounded-md text-base items-center justify-center"
|
document.getElementById(`${target}-form`).reset();
|
||||||
draggable="false">
|
location.reload();
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"
|
} else {
|
||||||
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
let json = await res.json();
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
document.getElementById(`${target}-message`).innerText = json.message;
|
||||||
d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12" />
|
}
|
||||||
</svg>
|
}
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{{embedFile "scripts/admin.js"}}}
|
/**
|
||||||
|
* 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) {
|
||||||
|
activeModal = modalKind;
|
||||||
|
targetCategoryID = categoryID;
|
||||||
|
|
||||||
|
pageElement.style.filter = "blur(20px)";
|
||||||
|
document.getElementById(modalKind + "-contents").classList.remove("hidden");
|
||||||
|
|
||||||
|
modalContainer.classList.add("is-visible");
|
||||||
|
modal.classList.add("is-visible");
|
||||||
|
|
||||||
|
if (document.getElementById(modalKind + "-form") !== null) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (document.getElementById(activeModal + "-form") !== null) {
|
||||||
|
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
|
||||||
|
el.classList.remove("invalid:border-[#861024]!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
targetCategoryID = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentlyEditingLink = {
|
||||||
|
ID: null,
|
||||||
|
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) {
|
||||||
|
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"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
let linkEl = document.querySelector(`[key="link-${currentlyDeletingLink.ID}"]`);
|
||||||
|
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) {
|
||||||
|
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"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
let categoryEl = document.querySelector(`[key="category-${currentlyDeletingCategory.ID}"]`);
|
||||||
|
// get the next element and remove it (its the link grid)
|
||||||
|
let nextEl = categoryEl.nextElementSibling;
|
||||||
|
nextEl.remove();
|
||||||
|
categoryEl.remove();
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces an H2 element with a resizable input field that matches its initial text and styling.
|
||||||
|
* @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];
|
||||||
|
});
|
||||||
|
|
||||||
|
inputElement.style.display = 'inline-block';
|
||||||
|
inputElement.style.backgroundColor = 'var(--color-base)';
|
||||||
|
inputElement.style.border = '1px solid var(--color-highlight-sm)';
|
||||||
|
inputElement.style.borderRadius = '0.375rem';
|
||||||
|
inputElement.maxLength = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to measure the text width accurately and apply it to the input.
|
||||||
|
* @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];
|
||||||
|
});
|
||||||
|
|
||||||
|
tempSpan.style.position = 'absolute';
|
||||||
|
tempSpan.style.visibility = 'hidden';
|
||||||
|
tempSpan.style.whiteSpace = 'nowrap';
|
||||||
|
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 {
|
||||||
|
inputEl.style.width = finalWidth + 'px';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => resizeInput(inputElement), 0);
|
||||||
|
inputElement.addEventListener('input', () => resizeInput(inputElement));
|
||||||
|
targetEl.parentNode.replaceChild(inputElement, targetEl);
|
||||||
|
|
||||||
|
inputElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -76,17 +76,17 @@
|
|||||||
<div class="w-full sm:w-4/5 p-2.5">
|
<div class="w-full sm:w-4/5 p-2.5">
|
||||||
{{#each Categories}}
|
{{#each Categories}}
|
||||||
<div class="flex items-center mt-2 first:mt-0">
|
<div class="flex items-center mt-2 first:mt-0">
|
||||||
<img class="object-contain mr-2 select-none size-8" width="32" height="32" draggable="false"
|
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
||||||
alt="{{this.Name}}" src="{{this.Icon}}" />
|
src="{{this.Icon}}" />
|
||||||
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-grid">
|
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
||||||
{{#each this.Links}}
|
{{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank"
|
||||||
<a href="{{this.URL}}" class="link-card" draggable="false" target="_blank" rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
<div data-img-container>
|
<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 data-text-container>
|
<div>
|
||||||
<h3>{{this.Name}}</h3>
|
<h3>{{this.Name}}</h3>
|
||||||
<p class="min-h-5">{{this.Description}}</p>
|
<p class="min-h-5">{{this.Description}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "passport",
|
"name": "passport",
|
||||||
"version": "0.3.2",
|
"version": "0.2.0",
|
||||||
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
|
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
|
||||||
"author": "juls0730",
|
"author": "juls0730",
|
||||||
"license": "BSL-1.0",
|
"license": "BSL-1.0",
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go",
|
"dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go",
|
||||||
"build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport src/main.go"
|
"build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport"
|
||||||
},
|
},
|
||||||
"pattern": "src/**/*.{go,hbs,js,css,scss,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"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user