From 6ad6da13ee5fbe83599f8e1ad609e69204e68601 Mon Sep 17 00:00:00 2001 From: Xevion Date: Wed, 14 Jan 2026 23:19:05 -0600 Subject: [PATCH] feat: add admin button to main layout, improve projects UI with click navigation, native scrollbar styles, better timestamp format - Add persistent admin dashboard button in header when authenticated - Make project list rows clickable for navigation to edit page - Move delete action to edit page with confirmation modal - Add sessionStorage persistence for auth state across page loads - Improve timestamp formatting with relative times - Add native scrollbar styling for light/dark themes --- web/src/app.css | 58 +++++++ web/src/lib/components/AdminButton.svelte | 14 ++ .../lib/components/admin/ProjectForm.svelte | 19 ++- web/src/lib/stores/auth.svelte.ts | 14 ++ web/src/routes/+layout.svelte | 10 +- web/src/routes/admin/projects/+page.server.ts | 32 +--- web/src/routes/admin/projects/+page.svelte | 148 ++++++------------ .../routes/admin/projects/[id]/+page.svelte | 59 ++++++- 8 files changed, 217 insertions(+), 137 deletions(-) create mode 100644 web/src/lib/components/AdminButton.svelte diff --git a/web/src/app.css b/web/src/app.css index dd943b3..1dd4f66 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -120,6 +120,64 @@ html.dark { border-radius: 4px; } +/* Native scrollbars (Webkit: Chrome, Safari, Edge) */ +html:not(.dark) ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +html:not(.dark) ::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; +} + +html:not(.dark) ::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.25); + border-radius: 4px; +} + +html:not(.dark) ::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.35); +} + +html:not(.dark) ::-webkit-scrollbar-thumb:active { + background: rgba(0, 0, 0, 0.45); +} + +html.dark ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +html.dark ::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; +} + +html.dark ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.35); + border-radius: 4px; +} + +html.dark ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.45); +} + +html.dark ::-webkit-scrollbar-thumb:active { + background: rgba(255, 255, 255, 0.55); +} + +/* Native scrollbars (Firefox) */ +html:not(.dark) { + scrollbar-color: rgba(0, 0, 0, 0.25) rgba(0, 0, 0, 0.05); + scrollbar-width: thin; +} + +html.dark { + scrollbar-color: rgba(255, 255, 255, 0.35) rgba(255, 255, 255, 0.05); + scrollbar-width: thin; +} + /* Utility class for page main wrapper */ .page-main { @apply relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300; diff --git a/web/src/lib/components/AdminButton.svelte b/web/src/lib/components/AdminButton.svelte new file mode 100644 index 0000000..d2608ab --- /dev/null +++ b/web/src/lib/components/AdminButton.svelte @@ -0,0 +1,14 @@ + + +{#if authStore.isAuthenticated} + + + +{/if} diff --git a/web/src/lib/components/admin/ProjectForm.svelte b/web/src/lib/components/admin/ProjectForm.svelte index cf6f1bf..c82159e 100644 --- a/web/src/lib/components/admin/ProjectForm.svelte +++ b/web/src/lib/components/admin/ProjectForm.svelte @@ -17,6 +17,7 @@ project?: AdminProject | null; availableTags: TagWithIcon[]; onsubmit: (data: CreateProjectData) => Promise; + ondelete?: () => void; submitLabel?: string; } @@ -24,6 +25,7 @@ project = null, availableTags, onsubmit, + ondelete, submitLabel = "Save Project", }: Props = $props(); @@ -182,10 +184,17 @@ -
- - +
+ {#if ondelete} + + {:else} +
+ {/if} +
+ + +
diff --git a/web/src/lib/stores/auth.svelte.ts b/web/src/lib/stores/auth.svelte.ts index 6fabcb8..152ef0d 100644 --- a/web/src/lib/stores/auth.svelte.ts +++ b/web/src/lib/stores/auth.svelte.ts @@ -1,9 +1,20 @@ import { telemetry } from "$lib/telemetry"; class AuthStore { + private static STORAGE_KEY = "admin_session_active"; + isAuthenticated = $state(false); username = $state(null); + init() { + if (typeof window === "undefined") return; + + const stored = sessionStorage.getItem(AuthStore.STORAGE_KEY); + if (stored === "true") { + this.isAuthenticated = true; + } + } + async login(username: string, password: string): Promise { try { const response = await fetch("/api/login", { @@ -19,6 +30,7 @@ class AuthStore { const data = await response.json(); this.isAuthenticated = true; this.username = data.username; + sessionStorage.setItem(AuthStore.STORAGE_KEY, "true"); telemetry.identifyAdmin(data.username); return true; } @@ -41,6 +53,7 @@ class AuthStore { } finally { this.isAuthenticated = false; this.username = null; + sessionStorage.removeItem(AuthStore.STORAGE_KEY); telemetry.reset(); } } @@ -69,6 +82,7 @@ class AuthStore { setSession(username: string): void { this.isAuthenticated = true; this.username = username; + sessionStorage.setItem(AuthStore.STORAGE_KEY, "true"); } } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index cb1effa..35f096f 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -7,12 +7,14 @@ import { OverlayScrollbars } from "overlayscrollbars"; import { onMount } from "svelte"; import { themeStore } from "$lib/stores/theme.svelte"; + import { authStore } from "$lib/stores/auth.svelte"; import { page } from "$app/stores"; import { afterNavigate, onNavigate } from "$app/navigation"; import { telemetry } from "$lib/telemetry"; import Clouds from "$lib/components/Clouds.svelte"; import Dots from "$lib/components/Dots.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte"; + import AdminButton from "$lib/components/AdminButton.svelte"; let { children, data } = $props(); @@ -84,6 +86,9 @@ // Initialize theme store themeStore.init(); + // Initialize auth store (checks sessionStorage for admin session) + authStore.init(); + // Initialize PostHog telemetry (page views tracked via afterNavigate) telemetry.init(); @@ -137,11 +142,12 @@ {/if} - +
+
{/if} diff --git a/web/src/routes/admin/projects/+page.server.ts b/web/src/routes/admin/projects/+page.server.ts index 566ad3a..437282f 100644 --- a/web/src/routes/admin/projects/+page.server.ts +++ b/web/src/routes/admin/projects/+page.server.ts @@ -1,36 +1,17 @@ import type { PageServerLoad } from "./$types"; import { apiFetch } from "$lib/api.server"; import { renderIconsBatch } from "$lib/server/icons"; -import type { - AdminProject, - ProjectStatus, - TagWithIcon, -} from "$lib/admin-types"; +import type { AdminProject, TagWithIcon } from "$lib/admin-types"; export interface ProjectWithTagIcons extends Omit { tags: TagWithIcon[]; } -// Status display configuration (icons for server-side rendering) -const STATUS_ICONS: Record = { - active: "lucide:circle-check", - maintained: "lucide:wrench", - archived: "lucide:archive", - hidden: "lucide:eye-off", -}; - export const load: PageServerLoad = async ({ fetch }) => { const projects = await apiFetch("/api/projects", { fetch }); - // Collect all icon identifiers for batch rendering + // Collect all tag icon identifiers for batch rendering const iconIds = new Set(); - - // Add status icons - for (const icon of Object.values(STATUS_ICONS)) { - iconIds.add(icon); - } - - // Add tag icons for (const project of projects) { for (const tag of project.tags) { if (tag.icon) { @@ -42,14 +23,6 @@ export const load: PageServerLoad = async ({ fetch }) => { // Batch render all icons const icons = await renderIconsBatch([...iconIds], { size: 12 }); - // Build status icons map - const statusIcons: Record = { - active: icons.get(STATUS_ICONS.active) ?? "", - maintained: icons.get(STATUS_ICONS.maintained) ?? "", - archived: icons.get(STATUS_ICONS.archived) ?? "", - hidden: icons.get(STATUS_ICONS.hidden) ?? "", - }; - // Map icons back to project tags const projectsWithIcons: ProjectWithTagIcons[] = projects.map((project) => ({ ...project, @@ -61,6 +34,5 @@ export const load: PageServerLoad = async ({ fetch }) => { return { projects: projectsWithIcons, - statusIcons, }; }; diff --git a/web/src/routes/admin/projects/+page.svelte b/web/src/routes/admin/projects/+page.svelte index 99a9994..8a8b6d2 100644 --- a/web/src/routes/admin/projects/+page.svelte +++ b/web/src/routes/admin/projects/+page.svelte @@ -1,16 +1,11 @@ @@ -134,16 +115,19 @@ > Last Activity - - Actions - {#each data.projects as project (project.id)} - + goto(`/admin/projects/${project.id}`)} + onkeydown={(e) => + (e.key === "Enter" || e.key === " ") && + goto(`/admin/projects/${project.id}`)} + role="link" + tabindex="0" + >
@@ -160,11 +144,15 @@ -
+ +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > {#each project.tags.slice(0, 3) as tag (tag.id)} {formatDate(project.lastActivity)} - -
- - -
- {/each} {/if}
- - - - {#if deleteTarget} -
-

{deleteTarget.name}

-

{deleteTarget.slug}

-
- {/if} -
diff --git a/web/src/routes/admin/projects/[id]/+page.svelte b/web/src/routes/admin/projects/[id]/+page.svelte index 7405932..edfaf77 100644 --- a/web/src/routes/admin/projects/[id]/+page.svelte +++ b/web/src/routes/admin/projects/[id]/+page.svelte @@ -2,12 +2,16 @@ import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import ProjectForm from "$lib/components/admin/ProjectForm.svelte"; - import { updateAdminProject } from "$lib/api"; + import Modal from "$lib/components/admin/Modal.svelte"; + import { updateAdminProject, deleteAdminProject } from "$lib/api"; import type { UpdateProjectData, CreateProjectData, TagWithIcon, } from "$lib/admin-types"; + import { getLogger } from "@logtape/logtape"; + + const logger = getLogger(["admin", "projects", "edit"]); interface Props { data: { @@ -18,6 +22,38 @@ let { data }: Props = $props(); + // Delete modal state + let deleteModalOpen = $state(false); + let deleteConfirmReady = $state(false); + let deleteTimeout: ReturnType | null = null; + + function initiateDelete() { + deleteConfirmReady = false; + deleteTimeout = setTimeout(() => { + deleteConfirmReady = true; + }, 2000); + deleteModalOpen = true; + } + + function cancelDelete() { + if (deleteTimeout) clearTimeout(deleteTimeout); + deleteModalOpen = false; + deleteConfirmReady = false; + } + + async function confirmDelete() { + if (!data.project || !deleteConfirmReady) return; + try { + await deleteAdminProject(data.project.id); + goto(resolve("/admin/projects")); + } catch (error) { + logger.error("Failed to delete project", { + error: error instanceof Error ? error.message : String(error), + }); + alert("Failed to delete project"); + } + } + async function handleSubmit(formData: CreateProjectData) { if (!data.project) return; @@ -60,8 +96,29 @@ project={data.project} availableTags={data.availableTags} onsubmit={handleSubmit} + ondelete={initiateDelete} submitLabel="Update Project" />
{/if}
+ + + + {#if data.project} +
+

{data.project.name}

+

{data.project.slug}

+
+ {/if} +