diff --git a/README.md b/README.md index 31a8e13..daa4c55 100644 --- a/README.md +++ b/README.md @@ -59,21 +59,25 @@ You can then run the binary. #### Weather configuration -| Environment Variable | Description | Required | Default | -| ----------------------------- | ------------------------------------------------------------------------- | ---------- | -------------- | -| `OPENWEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | true | openweathermap | -| `OPENWEATHER_API_KEY` | The OpenWeather API key | if enabled | | -| `OPENWEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric | -| `OPENWEATHER_LAT` | The latitude of your location | if enabled | | -| `OPENWEATHER_LON` | The longitude of your location | if enabled | | -| `OPENWEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 | +The following only applies if you are using the OpenWeather integration. + +| Environment Variable | Description | Required | Default | +| ----------------------------- | ------------------------------------------------------------------------- | -------- | -------------- | +| `OPENWEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | true | openweathermap | +| `OPENWEATHER_API_KEY` | The OpenWeather API key | true | | +| `OPENWEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric | +| `OPENWEATHER_LAT` | The latitude of your location | true | | +| `OPENWEATHER_LON` | The longitude of your location | true | | +| `OPENWEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 | #### Uptime configuration -| Environment Variable | Description | Required | Default | -| ----------------------------- | ------------------------------------------------- | ---------- | ------- | -| `UPTIMEROBOT_API_KEY` | The UptimeRobot API key | if enabled | | -| `UPTIMEROBOT_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 | +The following only applies if you are using the UptimeRobot integration. + +| Environment Variable | Description | Required | Default | +| ----------------------------- | ------------------------------------------------- | -------- | ------- | +| `UPTIMEROBOT_API_KEY` | The UptimeRobot API key | true | | +| `UPTIMEROBOT_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 | ### Adding links and categories diff --git a/main.go b/main.go index 0bc5a75..563f511 100644 --- a/main.go +++ b/main.go @@ -453,6 +453,7 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe case "image/webp": img, err = webp.Decode(srcFile) case "image/svg+xml": + // does not fall through (my C brain was tripping over this) default: return "", errors.New("unsupported file type") } @@ -552,19 +553,10 @@ func (manager *CategoryManager) GetCategories() []Category { // Get Category by ID, returns nil if not found func (manager *CategoryManager) GetCategory(id int64) *Category { - rows, err := manager.db.Query(` - SELECT id, name, icon - FROM categories - WHERE id = ? - `, id) - - if err != nil { - return nil - } - defer rows.Close() + row := manager.db.QueryRow(`SELECT id, name, icon FROM categories WHERE id = ?`, id) var cat Category - if err := rows.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil { + if err := row.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil { return nil } @@ -650,6 +642,17 @@ func (manager *CategoryManager) DeleteCategory(id int64) error { return nil } +func (manager *CategoryManager) GetLink(id int64) *Link { + row := manager.db.QueryRow(`SELECT id, category_id, name, description, icon, url FROM links WHERE id = ?`, id) + + var link Link + if err := row.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description, &link.Icon, &link.URL); err != nil { + return nil + } + + return &link +} + func (manager *CategoryManager) GetLinks(categoryID int64) []Link { rows, err := manager.db.Query(` SELECT id, category_id, name, description, icon, url @@ -939,7 +942,7 @@ func main() { return c.Next() }) - api.Post("/categories", func(c fiber.Ctx) error { + api.Post("/category", func(c fiber.Ctx) error { var req struct { Name string `form:"name"` } @@ -950,7 +953,9 @@ func main() { } if req.Name == "" { - return fmt.Errorf("name and icon are required") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Name is required", + }) } file, err := c.FormFile("icon") @@ -991,7 +996,9 @@ func main() { }) if err != nil { - return err + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": fmt.Sprintf("Failed to create category: %v", err), + }) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{ @@ -1000,12 +1007,11 @@ func main() { }) }) - api.Post("/links", func(c fiber.Ctx) error { + api.Post("/category/:id/link", func(c fiber.Ctx) error { var req struct { Name string `form:"name"` Description string `form:"description"` URL string `form:"url"` - CategoryID int64 `form:"category_id"` } if err := c.Bind().Form(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ @@ -1014,7 +1020,22 @@ func main() { } if req.Name == "" || req.URL == "" { - return fmt.Errorf("name and url are required") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Name and URL are required", + }) + } + + categoryID, 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 app.CategoryManager.GetCategory(categoryID) == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Category not found", + }) } file, err := c.FormFile("icon") @@ -1050,7 +1071,7 @@ func main() { UploadFile(file, iconPath, contentType, c) link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{ - CategoryID: req.CategoryID, + CategoryID: categoryID, Name: req.Name, Description: req.Description, Icon: iconPath, @@ -1069,27 +1090,70 @@ func main() { }) }) - api.Delete("/links/:id", func(c fiber.Ctx) error { - id := c.Params("id") - - err = app.CategoryManager.DeleteLink(id) + api.Delete("/category/:id/link/:linkID", func(c fiber.Ctx) error { + linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64) if err != nil { - return err + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": fmt.Sprintf("Failed to parse link ID: %v", err), + }) + } + + categoryID, 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 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", + }) + } + + if link.CategoryID != categoryID { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Invalid category ID", + }) + } + + err = app.CategoryManager.DeleteLink(linkID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": fmt.Sprintf("Failed to delete link: %v", err), + }) } return c.SendStatus(fiber.StatusOK) }) - api.Delete("/categories/:id", func(c fiber.Ctx) error { + api.Delete("/category/:id", func(c fiber.Ctx) error { // id = parseInt(c.Params("id")) id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { - return err + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": fmt.Sprintf("Failed to parse category ID: %v", err), + }) + } + + if app.CategoryManager.GetCategory(id) == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Category not found", + }) } err = app.CategoryManager.DeleteCategory(id) if err != nil { - return err + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": fmt.Sprintf("Failed to delete category: %v", err), + }) } return c.SendStatus(fiber.StatusOK) diff --git a/templates/views/admin/index.hbs b/templates/views/admin/index.hbs index dbdaf18..d079269 100644 --- a/templates/views/admin/index.hbs +++ b/templates/views/admin/index.hbs @@ -1,10 +1,23 @@ -
+
+ + + + + + + + Return to home + +
+ +
{{#each Categories}} -
+
{{this.Name}} -

{{this.Name}}

+

{{this.Name}}

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

{{this.Name}}

{{this.Description}}

- +
@@ -104,7 +119,8 @@
- +
@@ -121,6 +137,7 @@ function openCategoryModal() { pageElement.style.filter = "blur(20px)"; + document.getElementById("category-form").reset(); categoryModalBg.classList.add("is-visible"); categoryModal.classList.add("is-visible"); @@ -131,12 +148,17 @@ categoryModalBg.classList.remove("is-visible"); categoryModal.classList.remove("is-visible"); + + document.getElementById("category-form").querySelectorAll("[required]").forEach((el) => { + el.classList.remove("invalid:border-[#861024]"); + }); } function openLinkModal(categoryID) { targetCategoryID = categoryID; pageElement.style.filter = "blur(20px)"; + document.getElementById("link-form").reset(); linkModalBg.classList.add("is-visible"); linkModal.classList.add("is-visible"); @@ -147,35 +169,48 @@ linkModalBg.classList.remove("is-visible"); linkModal.classList.remove("is-visible"); + + document.getElementById("link-form").querySelectorAll("[required]").forEach((el) => { + el.classList.remove("invalid:border-[#861024]"); + }); } - async function deleteLink(linkID) { - let res = await fetch(`/api/links/${linkID}`, { + async function deleteLink(linkID, categoryID) { + let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, { method: "DELETE" }); if (res.status === 200) { - location.reload(); + let linkEl = document.querySelector(`[key="link-${linkID}"]`); + linkEl.remove(); } } async function deleteCategory(categoryID) { - let res = await fetch(`/api/categories/${categoryID}`, { + let res = await fetch(`/api/category/${categoryID}`, { method: "DELETE" }); if (res.status === 200) { - location.reload(); + let categoryEl = document.querySelector(`[key="category-${categoryID}"]`); + // get the next element and remove it (its the link grid) + let nextEl = categoryEl.nextElementSibling; + nextEl.remove(); + categoryEl.remove(); } } + document.getElementById("link-form").querySelector("button").addEventListener("click", (event) => { + document.getElementById("link-form").querySelectorAll("[required]").forEach((el) => { + el.classList.add("invalid:border-[#861024]"); + }); + }); + document.getElementById("link-form").addEventListener("submit", async (event) => { event.preventDefault(); let data = new FormData(event.target); - data.append("category_id", targetCategoryID); - - let res = await fetch(`/api/links`, { + let res = await fetch(`/api/category/${targetCategoryID}/link`, { method: "POST", body: data }); @@ -190,11 +225,17 @@ } }); + document.getElementById("category-form").querySelector("button").addEventListener("click", (event) => { + document.getElementById("category-form").querySelectorAll("[required]").forEach((el) => { + el.classList.add("invalid:border-[#861024]"); + }); + }); + document.getElementById("category-form").addEventListener("submit", async (event) => { event.preventDefault(); let data = new FormData(event.target); - let res = await fetch(`/api/categories`, { + let res = await fetch(`/api/category`, { method: "POST", body: data }); diff --git a/templates/views/index.hbs b/templates/views/index.hbs index 96295de..6238277 100644 --- a/templates/views/index.hbs +++ b/templates/views/index.hbs @@ -70,23 +70,24 @@
{{#each Categories}} -
- {{this.Name}} + {{this.Name}} -

{{this.Name}}

+

{{this.Name}}

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

{{this.Name}}

{{this.Description}}

+ {{else}} +

No links here, add one!

{{/each}}
{{/each}}