improve database handling and category management, enhance admin UI with animations

This commit is contained in:
Zoe
2025-09-22 18:49:53 -05:00
parent 8c18e81358
commit 5b8177bd12
6 changed files with 270 additions and 143 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,8 @@
passport passport
.env .env
passport.db* passport.db*
public/uploads/ public
zqdgr zqdgr
# compiled via go prepare
assets/tailwind.css assets/tailwind.css

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

288
main.go
View File

@@ -19,9 +19,12 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
@@ -143,13 +146,40 @@ type App struct {
db *sql.DB db *sql.DB
} }
func NewApp(dbPath string) (*App, error) { func (app *App) Close() error {
return app.db.Close()
}
func NewApp(dbPath string, options map[string]any) (*App, error) {
config, err := ParseConfig() config, err := ParseConfig()
if err != nil { if err != nil {
return nil, err return nil, err
} }
db, err := sql.Open("sqlite3", dbPath) file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
if os.IsPermission(err) {
return nil, fmt.Errorf("file %s is not readable and writable: %v", dbPath, err)
}
return nil, fmt.Errorf("failed to open file %s for read/write: %v", dbPath, err)
}
defer file.Close()
var connectionOpts string
for k, v := range options {
if connectionOpts != "" {
connectionOpts += "&"
}
connectionOpts += fmt.Sprintf("%s=%v", k, v)
}
db, err := sql.Open("sqlite3", fmt.Sprintf("%s?%s", dbPath, connectionOpts))
if err != nil {
return nil, err
}
err = db.Ping()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -481,72 +511,64 @@ type Link struct {
} }
type CategoryManager struct { type CategoryManager struct {
db *sql.DB db *sql.DB
Categories []Category
} }
func NewCategoryManager(db *sql.DB) (*CategoryManager, error) { func NewCategoryManager(db *sql.DB) (*CategoryManager, error) {
rows, err := db.Query(` return &CategoryManager{
db: db,
}, nil
}
func (manager *CategoryManager) GetCategories() []Category {
rows, err := manager.db.Query(`
SELECT id, name, icon SELECT id, name, icon
FROM categories FROM categories
ORDER BY id ASC ORDER BY id ASC
`) `)
if err != nil { if err != nil {
return nil, err return nil
} }
defer rows.Close() defer rows.Close()
var categories []Category var categories []Category
for rows.Next() { for rows.Next() {
var cat Category var cat Category
if err := rows.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil { if err := rows.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil {
return nil, err return nil
} }
rows, err := db.Query(`
SELECT id, category_id, name, description, icon, url
FROM links
WHERE category_id = ?
ORDER BY id ASC
`, cat.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var links []Link
for rows.Next() {
var link Link
if err := rows.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description,
&link.Icon, &link.URL); err != nil {
return nil, err
}
links = append(links, link)
}
cat.Links = links
categories = append(categories, cat) categories = append(categories, cat)
} }
return &CategoryManager{ for i, cat := range categories {
db: db, categories[i].Links = manager.GetLinks(cat.ID)
Categories: categories, }
}, nil
return categories
} }
// 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 {
var category *Category rows, err := manager.db.Query(`
SELECT id, name, icon
FROM categories
WHERE id = ?
`, id)
// probably potentially bad if err != nil {
for _, cat := range manager.Categories { return nil
if cat.ID == id { }
category = &cat defer rows.Close()
break
} var cat Category
if err := rows.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil {
return nil
} }
return category return &cat
} }
func (manager *CategoryManager) CreateCategory(category Category) (*Category, error) { func (manager *CategoryManager) CreateCategory(category Category) (*Category, error) {
@@ -569,11 +591,91 @@ func (manager *CategoryManager) CreateCategory(category Category) (*Category, er
} }
category.ID = categoryID category.ID = categoryID
manager.Categories = append(manager.Categories, category)
return &category, nil return &category, nil
} }
func (manager *CategoryManager) DeleteCategory(id int64) error {
rows, err := manager.db.Query(`
SELECT icon FROM categories WHERE id = ?
UNION
SELECT icon FROM links WHERE category_id = ?
`, id, id)
if err != nil {
return err
}
defer rows.Close()
var icons []string
for rows.Next() {
var icon string
if err := rows.Scan(&icon); err != nil {
return err
}
icons = append(icons, icon)
}
tx, err := manager.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM categories WHERE id = ?", id)
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM links WHERE category_id = ?", id)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
for _, icon := range icons {
if icon == "" {
continue
}
if err := os.Remove(filepath.Join("public/", icon)); err != nil {
return err
}
}
return nil
}
func (manager *CategoryManager) GetLinks(categoryID int64) []Link {
rows, err := manager.db.Query(`
SELECT id, category_id, name, description, icon, url
FROM links
WHERE category_id = ?
ORDER BY id ASC
`, categoryID)
if err != nil {
return nil
}
defer rows.Close()
var links []Link
for rows.Next() {
var link Link
if err := rows.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description,
&link.Icon, &link.URL); err != nil {
return nil
}
links = append(links, link)
}
return links
}
func (manager *CategoryManager) CreateLink(db *sql.DB, link Link) (*Link, error) { func (manager *CategoryManager) CreateLink(db *sql.DB, link Link) (*Link, error) {
var err error var err error
insertLinkStmt, err = db.Prepare(` insertLinkStmt, err = db.Prepare(`
@@ -592,20 +694,6 @@ func (manager *CategoryManager) CreateLink(db *sql.DB, link Link) (*Link, error)
link.ID = linkID link.ID = linkID
var cat *Category
for i, c := range manager.Categories {
if c.ID == link.CategoryID {
cat = &manager.Categories[i]
break
}
}
if cat == nil {
return nil, fmt.Errorf("category not found")
}
cat.Links = append(cat.Links, link)
return &link, nil return &link, nil
} }
@@ -673,9 +761,7 @@ func getWeatherIcon(iconId string) string {
} }
func init() { func init() {
if err := godotenv.Load(); err != nil { godotenv.Load()
fmt.Println("No .env file found, using default values")
}
} }
func main() { func main() {
@@ -683,11 +769,29 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
app, err := NewApp("passport.db?cache=shared&mode=rwc&_journal_mode=WAL") dbPath, err := filepath.Abs("passport.db")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
app, err := NewApp(dbPath, map[string]any{
"cache": "shared",
"mode": "rwc",
"_journal_mode": "WAL",
})
if err != nil {
log.Fatal(err)
}
defer app.Close()
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
<-c
app.Close()
os.Exit(0)
}()
templatesDir, err := fs.Sub(embeddedAssets, "templates") templatesDir, err := fs.Sub(embeddedAssets, "templates")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -727,6 +831,11 @@ func main() {
router.Use(helmet.New(helmet.ConfigDefault)) router.Use(helmet.New(helmet.ConfigDefault))
// redirect /favicon.ico to /assets/favicon.ico
router.Get("/favicon.ico", func(c fiber.Ctx) error {
return c.Redirect().To("/assets/favicon.ico")
})
router.Use("/", static.New("./public", static.Config{ router.Use("/", static.New("./public", static.Config{
Browse: false, Browse: false,
MaxAge: 31536000, MaxAge: 31536000,
@@ -741,7 +850,7 @@ func main() {
renderData := fiber.Map{ renderData := fiber.Map{
"SearchProviderURL": app.Config.SearchProvider.URL, "SearchProviderURL": app.Config.SearchProvider.URL,
"SearchParam": app.Config.SearchProvider.Query, "SearchParam": app.Config.SearchProvider.Query,
"Categories": app.CategoryManager.Categories, "Categories": app.CategoryManager.GetCategories(),
} }
if app.Config.WeatherEnabled { if app.Config.WeatherEnabled {
@@ -816,12 +925,13 @@ func main() {
} }
return c.Render("views/admin/index", fiber.Map{ return c.Render("views/admin/index", fiber.Map{
"Categories": app.CategoryManager.Categories, "Categories": app.CategoryManager.GetCategories(),
}, "layouts/main") }, "layouts/main")
}) })
api := router.Group("/api") api := router.Group("/api")
{ {
// all API routes require admin auth. No user needs to make api requests since the site is SSR
api.Use(func(c fiber.Ctx) error { api.Use(func(c fiber.Ctx) error {
if c.Locals("IsAdmin") == nil { if c.Locals("IsAdmin") == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"})
@@ -962,63 +1072,25 @@ func main() {
api.Delete("/links/:id", func(c fiber.Ctx) error { api.Delete("/links/:id", func(c fiber.Ctx) error {
id := c.Params("id") id := c.Params("id")
app.CategoryManager.DeleteLink(id) err = app.CategoryManager.DeleteLink(id)
if err != nil {
return err
}
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusOK)
}) })
api.Delete("/categories/:id", func(c fiber.Ctx) error { api.Delete("/categories/:id", func(c fiber.Ctx) error {
id := c.Params("id") // id = parseInt(c.Params("id"))
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
rows, err := app.db.Query(`
SELECT icon FROM categories WHERE id = ?
UNION
SELECT icon FROM links WHERE category_id = ?
`, id, id)
if err != nil { if err != nil {
return err return err
} }
defer rows.Close() err = app.CategoryManager.DeleteCategory(id)
var icons []string
for rows.Next() {
var icon string
if err := rows.Scan(&icon); err != nil {
return err
}
icons = append(icons, icon)
}
tx, err := app.db.Begin()
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM categories WHERE id = ?", id)
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM links WHERE category_id = ?", id)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
for _, icon := range icons {
if icon == "" {
continue
}
if err := os.Remove(filepath.Join("public/", icon)); err != nil {
return err
}
}
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusOK)
}) })

View File

@@ -1,4 +1,4 @@
<section class="flex justify-center w-full"> <section class="flex justify-center w-full transition-[filter] duration-300 ease-[cubic-bezier(0.45,_0,_0.55,_1)]">
<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">
@@ -31,7 +31,7 @@
</svg></button> </svg></button>
</div> </div>
{{/each}} {{/each}}
<div onclick="addLink({{this.ID}})" <div onclick="openLinkModal({{this.ID}})"
class="rounded-2xl border border-dashed border-[#656565] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] pointer-cursor select-none cursor-pointer"> class="rounded-2xl border border-dashed border-[#656565] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] 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"
@@ -48,14 +48,15 @@
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 5v14m-7-7h14" /> d="M12 5v14m-7-7h14" />
</svg> </svg>
<h2 onclick="addCategory()" class="text-[#656565] underline decoration-dashed cursor-pointer"> <h2 onclick="openCategoryModal()" class="text-[#656565] underline decoration-dashed cursor-pointer">
Add a new category Add a new category
</h2> </h2>
</div> </div>
</div> </div>
</section> </section>
<div id="linkModal" class="hidden absolute top-0 left-0 bottom-0 right-0 bg-[#00000070] justify-center items-center"> <div id="linkModal"
<div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4"> 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">
<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> <div>
@@ -78,7 +79,8 @@
</div> </div>
<div> <div>
<label for="linkIcon">Icon</label> <label for="linkIcon">Icon</label>
<input class="w-full text-white" type="file" name="icon" id="linkIcon" accept="image/*" /> <input class="w-full text-white py-2 px-4 rounded bg-[#1C1C21] border border-[#56565b]/30" 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</button>
</form> </form>
@@ -86,10 +88,11 @@
</div> </div>
</div> </div>
<div id="categoryModal" <div id="categoryModal"
class="hidden absolute 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"> <div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4 modal">
<h3>Create A category</h3> <h3>Create A category</h3>
<form id="category-form" action="/api/categories" method="post" class="flex flex-col gap-y-3 my-2"> <form id="category-form" action="/api/categories" method="post"
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
<div> <div>
<label for="categoryName">Name</label> <label for="categoryName">Name</label>
<input <input
@@ -98,7 +101,8 @@
</div> </div>
<div> <div>
<label for="linkIcon">Icon</label> <label for="linkIcon">Icon</label>
<input class="w-full text-white" type="file" name="icon" id="linkIcon" accept=".svg" /> <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" />
</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</button>
</form> </form>
@@ -108,19 +112,41 @@
<script> <script>
// idfk what this variable capitalization is, it's a mess // idfk what this variable capitalization is, it's a mess
let linkModal = document.getElementById("linkModal"); let linkModalBg = document.getElementById("linkModal");
let categoryModal = document.getElementById("categoryModal"); let linkModal = linkModalBg.querySelector("div");
let categoryModalBg = document.getElementById("categoryModal");
let categoryModal = categoryModalBg.querySelector("div");
let pageElement = document.querySelector("section");
let targetCategoryID = null; let targetCategoryID = null;
function addCategory() { function openCategoryModal() {
categoryModal.classList.remove("hidden"); pageElement.style.filter = "blur(20px)";
categoryModal.classList.add("flex");
categoryModalBg.classList.add("is-visible");
categoryModal.classList.add("is-visible");
} }
function addLink(categoryID) { function closeCategoryModal() {
pageElement.style.filter = "";
categoryModalBg.classList.remove("is-visible");
categoryModal.classList.remove("is-visible");
}
function openLinkModal(categoryID) {
targetCategoryID = categoryID; targetCategoryID = categoryID;
linkModal.classList.remove("hidden");
linkModal.classList.add("flex"); pageElement.style.filter = "blur(20px)";
linkModalBg.classList.add("is-visible");
linkModal.classList.add("is-visible");
}
function closeLinkModal() {
pageElement.style.filter = "";
linkModalBg.classList.remove("is-visible");
linkModal.classList.remove("is-visible");
} }
async function deleteLink(linkID) { async function deleteLink(linkID) {
@@ -155,8 +181,7 @@
}); });
if (res.status === 201) { if (res.status === 201) {
linkModal.classList.add("hidden"); closeLinkModal();
linkModal.classList.remove("flex");
document.getElementById("link-form").reset(); document.getElementById("link-form").reset();
location.reload(); location.reload();
} else { } else {
@@ -175,8 +200,7 @@
}); });
if (res.status === 201) { if (res.status === 201) {
categoryModal.classList.add("hidden"); closeCategoryModal()
categoryModal.classList.remove("flex");
document.getElementById("category-form").reset(); document.getElementById("category-form").reset();
location.reload(); location.reload();
} else { } else {
@@ -185,19 +209,47 @@
} }
}); });
linkModal.addEventListener("click", (event) => { linkModalBg.addEventListener("click", (event) => {
if (event.target === linkModal) { if (event.target === linkModalBg) {
targetCategoryID = null; targetCategoryID = null;
linkModal.classList.add("hidden"); closeLinkModal();
linkModal.classList.remove("flex");
} }
}); });
categoryModal.addEventListener("click", (event) => { categoryModalBg.addEventListener("click", (event) => {
if (event.target === categoryModal) { if (event.target === categoryModalBg) {
targetCategoryID = null; targetCategoryID = null;
categoryModal.classList.add("hidden"); closeCategoryModal();
categoryModal.classList.remove("flex");
} }
}); });
</script> </script>
<style>
.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>

View File

@@ -71,7 +71,7 @@
<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 w-fit">
<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 text-white" 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 w-fit">{{this.Name}}</h2>
</div> </div>

View File

@@ -11,7 +11,8 @@
}, },
"scripts": { "scripts": {
"dev": "go generate; PASSPORT_DEV_MODE=true go run main.go", "dev": "go generate; PASSPORT_DEV_MODE=true go run main.go",
"build": "go generate && go build -tags netgo,prod -o passport" "build": "go generate && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport"
}, },
"pattern": "**/*.go,templates/views/**/*.hbs,styles/**/*.css,assets/**/*.{svg,png,jpg,jpeg,webp,woff2,ttf,otf,eot,ico,gif,webp}" "pattern": "**/*.go,templates/views/**/*.hbs,styles/**/*.css,assets/**/*.{svg,png,jpg,jpeg,webp,woff2,ttf,otf,eot,ico,gif,webp}",
"shutdown_signal": "SIGINT"
} }