From 3de69f02f24e7c2de103b30b7d83d4eb267556d4 Mon Sep 17 00:00:00 2001 From: Zoe <62722391+juls0730@users.noreply.github.com> Date: Tue, 30 Sep 2025 01:06:52 -0500 Subject: [PATCH] Vastly overhaul admin UI 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. --- README.md | 3 + go.mod | 2 + go.sum | 6 + src/main.go | 370 +++++++- src/styles/{main.css => main.scss} | 36 +- .../partials/modals/category-form.hbs | 17 + .../partials/modals/delete-category.hbs | 13 + src/templates/partials/modals/delete-link.hbs | 12 + src/templates/partials/modals/link-form.hbs | 25 + src/templates/views/admin/index.hbs | 809 +++++++++++++++--- src/templates/views/index.hbs | 6 +- zqdgr.config.json | 4 +- 12 files changed, 1140 insertions(+), 163 deletions(-) rename src/styles/{main.css => main.scss} (73%) create mode 100644 src/templates/partials/modals/category-form.hbs create mode 100644 src/templates/partials/modals/delete-category.hbs create mode 100644 src/templates/partials/modals/delete-link.hbs create mode 100644 src/templates/partials/modals/link-form.hbs diff --git a/README.md b/README.md index f82dcd1..128c949 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/go.mod b/go.mod index 4ca8dd8..6f62166 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.25.0 require ( github.com/HugoSmits86/nativewebp v1.2.0 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 ) diff --git a/go.sum b/go.sum index f7c827e..941ae82 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vaui github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.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 +53,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/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= @@ -74,6 +78,7 @@ golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/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 +91,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.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= diff --git a/src/main.go b/src/main.go index 10de50f..ee6c008 100644 --- a/src/main.go +++ b/src/main.go @@ -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 @@ -27,6 +27,7 @@ import ( "github.com/HugoSmits86/nativewebp" "github.com/caarlos0/env/v11" + "github.com/disintegration/imaging" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/helmet" "github.com/gofiber/fiber/v3/middleware/static" @@ -35,6 +36,8 @@ 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" ) @@ -44,23 +47,36 @@ var embeddedAssets embed.FS var devContent = `` @@ -282,8 +298,47 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe return "", errors.New("unsupported file type") } - if err != nil { - return "", err + if contentType != "image/svg+xml" { + off, err := srcFile.Seek(0, io.SeekStart) + if err != nil { + return "", fmt.Errorf("failed to seek to start of file: %v", err) + } + + if off != 0 { + return "", fmt.Errorf("failed to seek to start of file: %v", err) + } + + x, err := exif.Decode(srcFile) + // if there *is* exif, parse it + if err == nil { + tag, err := x.Get(exif.Orientation) + if err != nil { + return "", fmt.Errorf("failed to get orientation: %v", err) + } + + if tag.Count == 1 && tag.Format() == tiff.IntVal { + orientation, err := tag.Int(0) + if err != nil { + return "", fmt.Errorf("failed to get orientation: %v", err) + } + + slog.Debug("Orientation tag found", "orientation", orientation) + + switch orientation { + case 3: + img = imaging.Rotate180(img) + case 6: + img = imaging.Rotate270(img) + case 8: + img = imaging.Rotate90(img) + } + } + } + + img, err = CropToCenter(img, 96) + if err != nil { + return "", err + } } assetsDir := "public/uploads" @@ -291,7 +346,21 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe 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 +370,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 } @@ -790,6 +852,14 @@ func main() { }) } + req.Name = strings.TrimSpace(req.Name) + + if len(req.Name) > 50 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Name is too long. Maximum length is 50 characters", + }) + } + file, err := c.FormFile("icon") if err != nil || file == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ @@ -810,7 +880,7 @@ func main() { }) } - filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) + filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_")) iconPath, err := UploadFile(file, filename, contentType, c) if err != nil { @@ -819,8 +889,6 @@ func main() { }) } - UploadFile(file, iconPath, contentType, c) - category, err := app.CategoryManager.CreateCategory(Category{ Name: req.Name, Icon: iconPath, @@ -857,6 +925,23 @@ func main() { }) } + req.Name = strings.TrimSpace(req.Name) + if req.Description != "" { + req.Description = strings.TrimSpace(req.Description) + } + + if len(req.Name) > 50 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Name is too long. Maximum length is 50 characters", + }) + } + + if len(req.Description) > 150 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Description is too long. Maximum length is 150 characters", + }) + } + categoryID, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ @@ -890,7 +975,7 @@ func main() { }) } - filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) + filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_")) iconPath, err := UploadFile(file, filename, contentType, c) if err != nil { @@ -900,8 +985,6 @@ func main() { }) } - UploadFile(file, iconPath, contentType, c) - link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{ CategoryID: categoryID, Name: req.Name, @@ -922,6 +1005,245 @@ func main() { }) }) + api.Patch("/category/:id", func(c fiber.Ctx) error { + var req struct { + Name string `form:"name"` + } + + if c.Params("id") == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "ID is required", + }) + } + + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": fmt.Sprintf("Failed to parse category ID: %v", err), + }) + } + + if err := c.Bind().Form(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Failed to parse request", + }) + } + + if req.Name != "" { + if len(req.Name) > 50 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Name is too long. Maximum length is 50 characters", + }) + } + } + + category := app.CategoryManager.GetCategory(id) + if category == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Category not found", + }) + } + + tx, err := app.db.Begin() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to start transaction", + }) + } + defer tx.Rollback() + + file, err := c.FormFile("icon") + if err == nil { + if file.Size > 5*1024*1024 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "File size too large. Maximum size is 5MB", + }) + } + + contentType := file.Header.Get("Content-Type") + if contentType != "image/svg+xml" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Only svg files are allowed", + }) + } + + oldIconPath := category.Icon + + filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_")) + + iconPath, err := UploadFile(file, filename, contentType, c) + if err != nil { + slog.Error("Failed to upload file", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to upload file, please try again!", + }) + } + + _, err = tx.Exec("UPDATE categories SET icon = ? WHERE id = ?", iconPath, id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to update category", + }) + } + + err = os.Remove(filepath.Join("public/", oldIconPath)) + if err != nil { + slog.Error("Failed to delete icon", "error", err) + } + } + + if req.Name != "" { + _, err = tx.Exec("UPDATE categories SET name = ? WHERE id = ?", req.Name, category.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to update category", + }) + } + } + + err = tx.Commit() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to commit transaction", + }) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "message": "Category updated successfully", + }) + }) + + api.Patch("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error { + var req struct { + Name string `form:"name"` + Description string `form:"description"` + Icon string `form:"icon"` + } + if err := c.Bind().Form(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Failed to parse request", + }) + } + + if len(req.Name) > 50 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Name is too long. Maximum length is 50 characters", + }) + } + + if len(req.Description) > 150 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Description is too long. Maximum length is 150 characters", + }) + } + + linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": fmt.Sprintf("Failed to parse link ID: %v", err), + }) + } + + categoryID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": fmt.Sprintf("Failed to parse category ID: %v", err), + }) + } + + if app.CategoryManager.GetCategory(categoryID) == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Category not found", + }) + } + + link := app.CategoryManager.GetLink(linkID) + if link == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Link not found", + }) + } + + tx, err := app.db.Begin() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to start transaction", + }) + } + defer tx.Rollback() + + file, err := c.FormFile("icon") + if err == nil { + if file.Size > 5*1024*1024 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "File size too large. Maximum size is 5MB", + }) + } + + contentType := file.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Only image files are allowed", + }) + } + + oldIconPath := link.Icon + + filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_")) + + iconPath, err := UploadFile(file, filename, contentType, c) + if err != nil { + slog.Error("Failed to upload file", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to upload file, please try again!", + }) + } + + _, err = tx.Exec("UPDATE links SET icon = ? WHERE id = ?", iconPath, linkID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to update link", + }) + } + + err = os.Remove(filepath.Join("public/", oldIconPath)) + if err != nil { + slog.Error("Failed to delete icon", "error", err) + } + } + + if req.Name != "" { + _, err = tx.Exec("UPDATE links SET name = ? WHERE id = ?", req.Name, linkID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to update link", + }) + } + } + + if req.Description != "" { + _, err = tx.Exec("UPDATE links SET description = ? WHERE id = ?", req.Description, linkID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to update link", + }) + } + } + + err = tx.Commit() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to commit transaction", + }) + } + + slog.Info("Link updated successfully", "id", linkID, "name", req.Name) + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "message": "Link updated successfully", + }) + }) + api.Delete("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error { linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64) if err != nil { diff --git a/src/styles/main.css b/src/styles/main.scss similarity index 73% rename from src/styles/main.css rename to src/styles/main.scss index 4b65f2a..1828323 100644 --- a/src/styles/main.css +++ b/src/styles/main.scss @@ -4,11 +4,11 @@ @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-error: oklch(53% 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-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); @@ -61,7 +61,7 @@ button { } input:not(.search) { - @apply px-4 py-2 rounded-md w-full bg-surface border border-highlight-sm/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden; + @apply px-4 py-2 rounded-md w-full bg-surface border border-highlight/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden; &[type="file"] { @apply p-0 cursor-pointer; @@ -85,14 +85,16 @@ input:not(.search) { 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); - } + &: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); + &:active { + box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1); + transform: translateY(2px); + } } } @@ -110,18 +112,24 @@ input:not(.search) { } } -.link-card img { +/* Div that holds the image */ +.link-card div:has(img):first-child { + flex-shrink: 0; margin-right: 0.5rem; +} + +.link-card div:first-child img { user-select: none; border-radius: 0.375rem; aspect-ratio: 1/1; object-fit: cover; } -.link-card div { +/* Div that holds the text */ +.link-card div:nth-child(2) { word-break: break-all; } -.link-card div p { +.link-card div:nth-child(2) p { color: var(--color-subtle); } \ No newline at end of file diff --git a/src/templates/partials/modals/category-form.hbs b/src/templates/partials/modals/category-form.hbs new file mode 100644 index 0000000..746f3dd --- /dev/null +++ b/src/templates/partials/modals/category-form.hbs @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/src/templates/partials/modals/delete-category.hbs b/src/templates/partials/modals/delete-category.hbs new file mode 100644 index 0000000..9f9257c --- /dev/null +++ b/src/templates/partials/modals/delete-category.hbs @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/src/templates/partials/modals/delete-link.hbs b/src/templates/partials/modals/delete-link.hbs new file mode 100644 index 0000000..e9b6897 --- /dev/null +++ b/src/templates/partials/modals/delete-link.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/src/templates/partials/modals/link-form.hbs b/src/templates/partials/modals/link-form.hbs new file mode 100644 index 0000000..0bdf923 --- /dev/null +++ b/src/templates/partials/modals/link-form.hbs @@ -0,0 +1,25 @@ + \ No newline at end of file diff --git a/src/templates/views/admin/index.hbs b/src/templates/views/admin/index.hbs index 4ed6080..c4b3e74 100644 --- a/src/templates/views/admin/index.hbs +++ b/src/templates/views/admin/index.hbs @@ -18,32 +18,129 @@
{{#each Categories}}
- {{this.Name}} -

{{this.Name}}

- +
+ {{this.Name}} + +
+

{{this.Name}}

+
+
+ + +
+ +
{{#each this.Links}} -