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 @@ - - Cancel - - {submitting ? "Saving..." : submitLabel} - + + {#if ondelete} + Delete + {:else} + + {/if} + + Cancel + + {submitting ? "Saving..." : submitLabel} + + 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)} - - - - Edit - - initiateDelete(project)} - > - Delete - - - {/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} +
{deleteTarget.name}
{deleteTarget.slug}
{data.project.name}
{data.project.slug}