V0.3.2: Improved admin UI and performance galore
Some checks failed
Build and Push Docker Image to GHCR / build-and-push (push) Failing after 21m16s

This commit fixes a plethora of bugs related to the admin UI, as well as
dramatically improving the performance of in-place editing. Furthermore,
several server bugs and misc bugs have been fixed. The admin UI is now
entirely client side when adding, deleting, or editng a category or
link. Other internal improvements hasve also been made.
This commit is contained in:
Zoe
2025-10-02 00:16:53 -05:00
parent 75fe60b4c9
commit f6ffc90ec2
12 changed files with 1383 additions and 990 deletions

View File

@@ -17,7 +17,6 @@ import (
"log/slog"
"mime/multipart"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
@@ -27,9 +26,11 @@ import (
"time"
"github.com/HugoSmits86/nativewebp"
"github.com/NarmadaWeb/gonify/v3"
"github.com/caarlos0/env/v11"
"github.com/disintegration/imaging"
"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/static"
"github.com/gofiber/template/handlebars/v2"
@@ -43,7 +44,7 @@ import (
_ "modernc.org/sqlite"
)
//go:embed assets/** templates/** schema.sql
//go:embed assets/** templates/** schema.sql scripts/**.js styles/**.css
var embeddedAssets embed.FS
var devContent = `<script>
@@ -278,7 +279,14 @@ func CropToCenter(img image.Image, outputSize int) (image.Image, error) {
return outputImg, nil
}
func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fiber.Ctx) (string, error) {
func UploadFile(file *multipart.FileHeader, 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()
if err != nil {
return "", err
@@ -380,7 +388,7 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
}
}
iconPath = "/uploads/" + url.PathEscape(fileName)
iconPath = "/uploads/" + fileName
return iconPath, nil
}
@@ -698,16 +706,23 @@ func main() {
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.AddFunc("inlineCSS", func() string {
return string(css)
engine.AddFunc("embedFile", func(fileToEmbed string) string {
content, err := fs.ReadFile(embeddedAssets, fileToEmbed)
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 {
@@ -717,10 +732,6 @@ func main() {
return ""
})
engine.AddFunc("eq", func(a, b any) bool {
return a == b
})
router := fiber.New(fiber.Config{
Views: engine,
})
@@ -732,6 +743,17 @@ func main() {
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{
Browse: false,
MaxAge: 31536000,
@@ -822,7 +844,7 @@ func main() {
return c.Render("views/admin/index", fiber.Map{
"Categories": app.CategoryManager.GetCategories(),
}, "layouts/main")
}, "layouts/admin")
})
api := router.Group("/api")
@@ -879,9 +901,7 @@ func main() {
})
}
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)
iconPath, err := UploadFile(file, contentType, c)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to upload file, please try again!",
@@ -974,9 +994,7 @@ func main() {
})
}
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)
iconPath, err := UploadFile(file, contentType, c)
if err != nil {
slog.Error("Failed to upload file", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -1068,9 +1086,7 @@ func main() {
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)
iconPath, err := UploadFile(file, contentType, c)
if err != nil {
slog.Error("Failed to upload file", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -1188,9 +1204,7 @@ func main() {
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)
iconPath, err := UploadFile(file, contentType, c)
if err != nil {
slog.Error("Failed to upload file", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{

1106
src/scripts/admin.js Normal file

File diff suppressed because it is too large Load Diff

70
src/styles/adminUi.css Normal file
View File

@@ -0,0 +1,70 @@
.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%);
}
}

View File

@@ -1,12 +1,11 @@
@import "tailwindcss";
@theme {
--color-accent: oklch(57.93% 0.258 294.12);
--color-success: oklch(70.19% 0.158 160.44);
--color-error: oklch(53% 0.251 28.48);
--color-base: oklch(11% .007 285);
--color-base: oklch(11% 0.007 285);
--color-surface: oklch(19% 0.007 285.66);
--color-overlay: oklch(26% 0.008 285.66);
@@ -15,18 +14,21 @@
--color-text: oklch(87% 0.015 286);
--color-highlight-sm: oklch(30.67% 0.007 286);
--color-highlight: oklch(39.26% 0.010 286);
--color-highlight: oklch(39.26% 0.01 286);
--color-highlight-lg: oklch(47.72% 0.011 286);
}
@font-face {
font-family: "Instrument Sans";
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2");
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2")
format("woff2");
font-display: swap;
}
:root {
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
}
html {
@@ -35,25 +37,27 @@ html {
color: var(--color-text);
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-semibold;
}
@layer base {
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
}
h1 {
font-size: clamp(42px, 10vw, 64px);
}
h1 {
font-size: clamp(42px, 10vw, 64px);
}
h2 {
font-size: clamp(30px, 6vw, 36px);
}
h2 {
font-size: clamp(30px, 6vw, 36px);
}
h3 {
font-size: 1.25rem;
h3 {
font-size: 1.25rem;
}
}
button {
@@ -62,7 +66,7 @@ button {
input:not(.search) {
@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;
@@ -88,7 +92,8 @@ input:not(.search) {
&: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);
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);
}
@@ -116,7 +121,7 @@ input:not(.search) {
/* Div that holds the image */
.link-card div[data-img-container] {
flex-shrink: 0;
margin-right: 0.5rem;
padding-right: 0.5rem;
}
.link-card div[data-img-container] img {
@@ -128,13 +133,36 @@ input:not(.search) {
/* Div that holds the text */
.link-card div[data-text-container] {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 1px;
overflow: hidden;
word-break: break-all;
}
.link-card div[data-text-container] p {
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;
@@ -156,4 +184,12 @@ input:not(.search) {
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;
}

View File

@@ -0,0 +1,20 @@
<!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>

View File

@@ -3,14 +3,17 @@
<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">
<style>{{{inlineCSS}}}</style>
<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"}}}
</head>
<body class="bg-surface text-text">
{{embed}}
</body>
{{{devContent}}}
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -80,9 +80,9 @@
alt="{{this.Name}}" src="{{this.Icon}}" />
<h2 class="capitalize break-all">{{this.Name}}</h2>
</div>
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
{{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank"
rel="noopener noreferrer">
<div class="link-grid">
{{#each this.Links}}
<a href="{{this.URL}}" class="link-card" draggable="false" target="_blank" rel="noopener noreferrer">
<div data-img-container>
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
</div>