style changes, nicer link management, api reorg
Some checks are pending
Build and Push Docker Image to GHCR / build-and-push (push) Waiting to run
Some checks are pending
Build and Push Docker Image to GHCR / build-and-push (push) Waiting to run
This commit is contained in:
28
README.md
28
README.md
@@ -59,21 +59,25 @@ You can then run the binary.
|
|||||||
|
|
||||||
#### Weather configuration
|
#### Weather configuration
|
||||||
|
|
||||||
| Environment Variable | Description | Required | Default |
|
The following only applies if you are using the OpenWeather integration.
|
||||||
| ----------------------------- | ------------------------------------------------------------------------- | ---------- | -------------- |
|
|
||||||
| `OPENWEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | true | openweathermap |
|
| Environment Variable | Description | Required | Default |
|
||||||
| `OPENWEATHER_API_KEY` | The OpenWeather API key | if enabled | |
|
| ----------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
|
||||||
| `OPENWEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
|
| `OPENWEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | true | openweathermap |
|
||||||
| `OPENWEATHER_LAT` | The latitude of your location | if enabled | |
|
| `OPENWEATHER_API_KEY` | The OpenWeather API key | true | |
|
||||||
| `OPENWEATHER_LON` | The longitude of your location | if enabled | |
|
| `OPENWEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
|
||||||
| `OPENWEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
|
| `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
|
#### Uptime configuration
|
||||||
|
|
||||||
| Environment Variable | Description | Required | Default |
|
The following only applies if you are using the UptimeRobot integration.
|
||||||
| ----------------------------- | ------------------------------------------------- | ---------- | ------- |
|
|
||||||
| `UPTIMEROBOT_API_KEY` | The UptimeRobot API key | if enabled | |
|
| Environment Variable | Description | Required | Default |
|
||||||
| `UPTIMEROBOT_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
|
| ----------------------------- | ------------------------------------------------- | -------- | ------- |
|
||||||
|
| `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
|
### Adding links and categories
|
||||||
|
|
||||||
|
|||||||
116
main.go
116
main.go
@@ -453,6 +453,7 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
|
|||||||
case "image/webp":
|
case "image/webp":
|
||||||
img, err = webp.Decode(srcFile)
|
img, err = webp.Decode(srcFile)
|
||||||
case "image/svg+xml":
|
case "image/svg+xml":
|
||||||
|
// does not fall through (my C brain was tripping over this)
|
||||||
default:
|
default:
|
||||||
return "", errors.New("unsupported file type")
|
return "", errors.New("unsupported file type")
|
||||||
}
|
}
|
||||||
@@ -552,19 +553,10 @@ func (manager *CategoryManager) GetCategories() []Category {
|
|||||||
|
|
||||||
// Get Category by ID, returns nil if not found
|
// Get Category by ID, returns nil if not found
|
||||||
func (manager *CategoryManager) GetCategory(id int64) *Category {
|
func (manager *CategoryManager) GetCategory(id int64) *Category {
|
||||||
rows, err := manager.db.Query(`
|
row := manager.db.QueryRow(`SELECT id, name, icon FROM categories WHERE id = ?`, id)
|
||||||
SELECT id, name, icon
|
|
||||||
FROM categories
|
|
||||||
WHERE id = ?
|
|
||||||
`, id)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var cat Category
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,6 +642,17 @@ func (manager *CategoryManager) DeleteCategory(id int64) error {
|
|||||||
return nil
|
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 {
|
func (manager *CategoryManager) GetLinks(categoryID int64) []Link {
|
||||||
rows, err := manager.db.Query(`
|
rows, err := manager.db.Query(`
|
||||||
SELECT id, category_id, name, description, icon, url
|
SELECT id, category_id, name, description, icon, url
|
||||||
@@ -939,7 +942,7 @@ func main() {
|
|||||||
return c.Next()
|
return c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
api.Post("/categories", func(c fiber.Ctx) error {
|
api.Post("/category", func(c fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `form:"name"`
|
Name string `form:"name"`
|
||||||
}
|
}
|
||||||
@@ -950,7 +953,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Name == "" {
|
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")
|
file, err := c.FormFile("icon")
|
||||||
@@ -991,7 +996,9 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
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{
|
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 {
|
var req struct {
|
||||||
Name string `form:"name"`
|
Name string `form:"name"`
|
||||||
Description string `form:"description"`
|
Description string `form:"description"`
|
||||||
URL string `form:"url"`
|
URL string `form:"url"`
|
||||||
CategoryID int64 `form:"category_id"`
|
|
||||||
}
|
}
|
||||||
if err := c.Bind().Form(&req); err != nil {
|
if err := c.Bind().Form(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
@@ -1014,7 +1020,22 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Name == "" || req.URL == "" {
|
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")
|
file, err := c.FormFile("icon")
|
||||||
@@ -1050,7 +1071,7 @@ func main() {
|
|||||||
UploadFile(file, iconPath, contentType, c)
|
UploadFile(file, iconPath, contentType, c)
|
||||||
|
|
||||||
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
|
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
|
||||||
CategoryID: req.CategoryID,
|
CategoryID: categoryID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Icon: iconPath,
|
Icon: iconPath,
|
||||||
@@ -1069,27 +1090,70 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
api.Delete("/links/:id", func(c fiber.Ctx) error {
|
api.Delete("/category/:id/link/:linkID", func(c fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
|
||||||
|
|
||||||
err = app.CategoryManager.DeleteLink(id)
|
|
||||||
if err != nil {
|
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)
|
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 = parseInt(c.Params("id"))
|
||||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||||
if err != nil {
|
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)
|
err = app.CategoryManager.DeleteCategory(id)
|
||||||
if err != nil {
|
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)
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
<section class="flex justify-center w-full transition-[filter] duration-300 ease-[cubic-bezier(0.45,_0,_0.55,_1)]">
|
<header class="flex w-full p-3">
|
||||||
|
<a href="/" class="flex items-center flex-row gap-2 text-white border-b hover:border-transparent justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"
|
||||||
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||||
|
<path d="m9 14l-4-4l4-4" />
|
||||||
|
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Return to home
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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">
|
<div class="flex items-center" key="category-{{this.ID}}">
|
||||||
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
||||||
src="{{this.Icon}}" />
|
src="{{this.Icon}}" />
|
||||||
<h2 class="capitalize">{{this.Name}}</h2>
|
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
||||||
<button onclick="deleteCategory({{this.ID}})"
|
<button onclick="deleteCategory({{this.ID}})"
|
||||||
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] cursor-pointer"><svg
|
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] cursor-pointer"><svg
|
||||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
@@ -14,15 +27,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
||||||
{{#each this.Links}}
|
{{#each this.Links}}
|
||||||
<div
|
<div key="link-{{this.ID}}"
|
||||||
class="rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform,translate] ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-1 relative">
|
class="rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform,translate] ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-1 relative">
|
||||||
<img class="object-contain mr-2 select-none rounded-md" width="64" height="64" draggable="false"
|
<img class="mr-2 select-none rounded-md aspect-square object-cover" width="64" height="64"
|
||||||
src="{{this.Icon}}" alt="{{this.Name}}" />
|
draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||||
<div>
|
<div class="break-all">
|
||||||
<h3>{{this.Name}}</h3>
|
<h3>{{this.Name}}</h3>
|
||||||
<p class="text-[#D7D7D7]">{{this.Description}}</p>
|
<p class="text-[#D7D7D7]">{{this.Description}}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="deleteLink({{this.ID}})"
|
<button onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
|
||||||
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] cursor-pointer absolute right-1 top-1"><svg
|
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] cursor-pointer absolute right-1 top-1"><svg
|
||||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
|
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
|
||||||
@@ -58,31 +71,33 @@
|
|||||||
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-[#00000070] justify-center items-center">
|
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-[#00000070] justify-center items-center">
|
||||||
<div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4 modal">
|
<div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4 modal">
|
||||||
<h3>Add A link</h3>
|
<h3>Add A link</h3>
|
||||||
<form id="link-form" action="/api/links" method="post" class="flex flex-col gap-y-3 my-2">
|
<form id="link-form" action="/api/links" method="post"
|
||||||
|
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
||||||
<div>
|
<div>
|
||||||
<label for="linkName">Name</label>
|
<label for="linkName">Name</label>
|
||||||
<input
|
<input required
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none transition-colors duration-300 ease-out"
|
||||||
type="text" name="name" placeholder="Name" id="linkName" />
|
type="text" name="name" placeholder="Name" id="linkName" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="linkDesc">Description</label>
|
<label for="linkDesc">Description (optional)</label>
|
||||||
<input
|
<input
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none transition-colors duration-300 ease-out"
|
||||||
type="text" name="description" placeholder="Description" id="linkDesc" />
|
type="text" name="description" placeholder="Description" id="linkDesc" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="linkURL">URL</label>
|
<label for="linkURL">URL</label>
|
||||||
<input
|
<input required
|
||||||
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
|
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none transition-colors duration-300 ease-out"
|
||||||
type="text" name="url" placeholder="URL" id="linkURL" />
|
type="url" name="url" placeholder="URL" id="linkURL" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="linkIcon">Icon</label>
|
<label for="linkIcon">Icon</label>
|
||||||
<input class="w-full text-white py-2 px-4 rounded bg-[#1C1C21] border border-[#56565b]/30" type="file"
|
<input required
|
||||||
name="icon" id="linkIcon" accept="image/*" />
|
class="w-full text-white py-2 px-4 rounded bg-[#1C1C21] border border-[#56565b]/30 transition-colors duration-300 ease-out"
|
||||||
|
type="file" name="icon" id="linkIcon" accept="image/*" />
|
||||||
</div>
|
</div>
|
||||||
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Add</button>
|
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Add link</button>
|
||||||
</form>
|
</form>
|
||||||
<span id="link-message"></span>
|
<span id="link-message"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +119,8 @@
|
|||||||
<input class="w-full text-white py-2 px-4 rounded bg-[#1C1C21] border border-[#56565b]/30" type="file"
|
<input class="w-full text-white py-2 px-4 rounded bg-[#1C1C21] border border-[#56565b]/30" type="file"
|
||||||
name="icon" id="linkIcon" accept=".svg" />
|
name="icon" id="linkIcon" accept=".svg" />
|
||||||
</div>
|
</div>
|
||||||
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Create</button>
|
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Create
|
||||||
|
category</button>
|
||||||
</form>
|
</form>
|
||||||
<span id="category-message"></span>
|
<span id="category-message"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,6 +137,7 @@
|
|||||||
|
|
||||||
function openCategoryModal() {
|
function openCategoryModal() {
|
||||||
pageElement.style.filter = "blur(20px)";
|
pageElement.style.filter = "blur(20px)";
|
||||||
|
document.getElementById("category-form").reset();
|
||||||
|
|
||||||
categoryModalBg.classList.add("is-visible");
|
categoryModalBg.classList.add("is-visible");
|
||||||
categoryModal.classList.add("is-visible");
|
categoryModal.classList.add("is-visible");
|
||||||
@@ -131,12 +148,17 @@
|
|||||||
|
|
||||||
categoryModalBg.classList.remove("is-visible");
|
categoryModalBg.classList.remove("is-visible");
|
||||||
categoryModal.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) {
|
function openLinkModal(categoryID) {
|
||||||
targetCategoryID = categoryID;
|
targetCategoryID = categoryID;
|
||||||
|
|
||||||
pageElement.style.filter = "blur(20px)";
|
pageElement.style.filter = "blur(20px)";
|
||||||
|
document.getElementById("link-form").reset();
|
||||||
|
|
||||||
linkModalBg.classList.add("is-visible");
|
linkModalBg.classList.add("is-visible");
|
||||||
linkModal.classList.add("is-visible");
|
linkModal.classList.add("is-visible");
|
||||||
@@ -147,35 +169,48 @@
|
|||||||
|
|
||||||
linkModalBg.classList.remove("is-visible");
|
linkModalBg.classList.remove("is-visible");
|
||||||
linkModal.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) {
|
async function deleteLink(linkID, categoryID) {
|
||||||
let res = await fetch(`/api/links/${linkID}`, {
|
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
location.reload();
|
let linkEl = document.querySelector(`[key="link-${linkID}"]`);
|
||||||
|
linkEl.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCategory(categoryID) {
|
async function deleteCategory(categoryID) {
|
||||||
let res = await fetch(`/api/categories/${categoryID}`, {
|
let res = await fetch(`/api/category/${categoryID}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
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) => {
|
document.getElementById("link-form").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let data = new FormData(event.target);
|
let data = new FormData(event.target);
|
||||||
|
|
||||||
data.append("category_id", targetCategoryID);
|
let res = await fetch(`/api/category/${targetCategoryID}/link`, {
|
||||||
|
|
||||||
let res = await fetch(`/api/links`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: data
|
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) => {
|
document.getElementById("category-form").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let data = new FormData(event.target);
|
let data = new FormData(event.target);
|
||||||
|
|
||||||
let res = await fetch(`/api/categories`, {
|
let res = await fetch(`/api/category`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: data
|
body: data
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,23 +70,24 @@
|
|||||||
<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 w-fit">
|
<div class="flex items-center mt-2 first:mt-0">
|
||||||
<img class="object-contain mr-2 select-none text-white" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
||||||
src="{{this.Icon}}" />
|
src="{{this.Icon}}" />
|
||||||
<h2 class="capitalize w-fit">{{this.Name}}</h2>
|
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
||||||
{{#each this.Links}}
|
{{#each this.Links}} <a href="{{this.URL}}"
|
||||||
<a href="{{this.URL}}"
|
|
||||||
class="underline-none text-unset rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform,translate] ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-1"
|
class="underline-none text-unset rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform,translate] ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-1"
|
||||||
draggable="false" target="_blank" rel="noopener noreferrer">
|
draggable="false" target="_blank" rel="noopener noreferrer">
|
||||||
<img class="object-contain mr-2 select-none rounded-md" width="64" height="64" draggable="false"
|
<img class="mr-2 select-none rounded-md aspect-square object-cover" width="64" height="64"
|
||||||
src="{{this.Icon}}" alt="{{this.Name}}" />
|
draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||||
<div>
|
<div class="break-all">
|
||||||
<h3>{{this.Name}}</h3>
|
<h3>{{this.Name}}</h3>
|
||||||
<p class="text-[#D7D7D7]">{{this.Description}}</p>
|
<p class="text-[#D7D7D7]">{{this.Description}}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-[#D7D7D7]">No links here, add one!</p>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
Reference in New Issue
Block a user