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
This commit is contained in:
2026-01-14 23:19:05 -06:00
parent 89e1ab097d
commit 6ad6da13ee
8 changed files with 217 additions and 137 deletions
+58
View File
@@ -120,6 +120,64 @@ html.dark {
border-radius: 4px; 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 */ /* Utility class for page main wrapper */
.page-main { .page-main {
@apply relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300; @apply relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300;
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import { authStore } from "$lib/stores/auth.svelte";
import IconLayoutDashboard from "~icons/lucide/layout-dashboard";
</script>
{#if authStore.isAuthenticated}
<a
href="/admin"
aria-label="Admin dashboard"
class="relative size-9 rounded-md border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/50 hover:bg-zinc-200 dark:hover:bg-zinc-800/70 transition-all duration-200 cursor-pointer flex items-center justify-center"
>
<IconLayoutDashboard class="size-5 text-zinc-600 dark:text-zinc-400" />
</a>
{/if}
@@ -17,6 +17,7 @@
project?: AdminProject | null; project?: AdminProject | null;
availableTags: TagWithIcon[]; availableTags: TagWithIcon[];
onsubmit: (data: CreateProjectData) => Promise<void>; onsubmit: (data: CreateProjectData) => Promise<void>;
ondelete?: () => void;
submitLabel?: string; submitLabel?: string;
} }
@@ -24,6 +25,7 @@
project = null, project = null,
availableTags, availableTags,
onsubmit, onsubmit,
ondelete,
submitLabel = "Save Project", submitLabel = "Save Project",
}: Props = $props(); }: Props = $props();
@@ -182,10 +184,17 @@
<MediaManager projectId={project?.id ?? null} media={project?.media ?? []} /> <MediaManager projectId={project?.id ?? null} media={project?.media ?? []} />
<!-- Actions --> <!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border"> <div class="flex justify-between gap-3 pt-4 border-t border-admin-border">
<Button variant="secondary" href="/admin/projects">Cancel</Button> {#if ondelete}
<Button type="submit" variant="primary" disabled={submitting || !name}> <Button variant="danger" onclick={ondelete}>Delete</Button>
{submitting ? "Saving..." : submitLabel} {:else}
</Button> <div></div>
{/if}
<div class="flex gap-3">
<Button variant="secondary" href="/admin/projects">Cancel</Button>
<Button type="submit" variant="primary" disabled={submitting || !name}>
{submitting ? "Saving..." : submitLabel}
</Button>
</div>
</div> </div>
</form> </form>
+14
View File
@@ -1,9 +1,20 @@
import { telemetry } from "$lib/telemetry"; import { telemetry } from "$lib/telemetry";
class AuthStore { class AuthStore {
private static STORAGE_KEY = "admin_session_active";
isAuthenticated = $state(false); isAuthenticated = $state(false);
username = $state<string | null>(null); username = $state<string | null>(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<boolean> { async login(username: string, password: string): Promise<boolean> {
try { try {
const response = await fetch("/api/login", { const response = await fetch("/api/login", {
@@ -19,6 +30,7 @@ class AuthStore {
const data = await response.json(); const data = await response.json();
this.isAuthenticated = true; this.isAuthenticated = true;
this.username = data.username; this.username = data.username;
sessionStorage.setItem(AuthStore.STORAGE_KEY, "true");
telemetry.identifyAdmin(data.username); telemetry.identifyAdmin(data.username);
return true; return true;
} }
@@ -41,6 +53,7 @@ class AuthStore {
} finally { } finally {
this.isAuthenticated = false; this.isAuthenticated = false;
this.username = null; this.username = null;
sessionStorage.removeItem(AuthStore.STORAGE_KEY);
telemetry.reset(); telemetry.reset();
} }
} }
@@ -69,6 +82,7 @@ class AuthStore {
setSession(username: string): void { setSession(username: string): void {
this.isAuthenticated = true; this.isAuthenticated = true;
this.username = username; this.username = username;
sessionStorage.setItem(AuthStore.STORAGE_KEY, "true");
} }
} }
+8 -2
View File
@@ -7,12 +7,14 @@
import { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbars } from "overlayscrollbars";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { themeStore } from "$lib/stores/theme.svelte"; import { themeStore } from "$lib/stores/theme.svelte";
import { authStore } from "$lib/stores/auth.svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { afterNavigate, onNavigate } from "$app/navigation"; import { afterNavigate, onNavigate } from "$app/navigation";
import { telemetry } from "$lib/telemetry"; import { telemetry } from "$lib/telemetry";
import Clouds from "$lib/components/Clouds.svelte"; import Clouds from "$lib/components/Clouds.svelte";
import Dots from "$lib/components/Dots.svelte"; import Dots from "$lib/components/Dots.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import AdminButton from "$lib/components/AdminButton.svelte";
let { children, data } = $props(); let { children, data } = $props();
@@ -84,6 +86,9 @@
// Initialize theme store // Initialize theme store
themeStore.init(); themeStore.init();
// Initialize auth store (checks sessionStorage for admin session)
authStore.init();
// Initialize PostHog telemetry (page views tracked via afterNavigate) // Initialize PostHog telemetry (page views tracked via afterNavigate)
telemetry.init(); telemetry.init();
@@ -137,11 +142,12 @@
<Dots style="view-transition-name: background" /> <Dots style="view-transition-name: background" />
{/if} {/if}
<!-- Theme toggle - persistent across page transitions --> <!-- Header buttons - persistent across page transitions -->
<div <div
class="fixed top-5 right-6 z-50" class="fixed top-5 right-6 z-50 flex items-center gap-2"
style="view-transition-name: theme-toggle" style="view-transition-name: theme-toggle"
> >
<AdminButton />
<ThemeToggle /> <ThemeToggle />
</div> </div>
{/if} {/if}
+2 -30
View File
@@ -1,36 +1,17 @@
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server"; import { apiFetch } from "$lib/api.server";
import { renderIconsBatch } from "$lib/server/icons"; import { renderIconsBatch } from "$lib/server/icons";
import type { import type { AdminProject, TagWithIcon } from "$lib/admin-types";
AdminProject,
ProjectStatus,
TagWithIcon,
} from "$lib/admin-types";
export interface ProjectWithTagIcons extends Omit<AdminProject, "tags"> { export interface ProjectWithTagIcons extends Omit<AdminProject, "tags"> {
tags: TagWithIcon[]; tags: TagWithIcon[];
} }
// Status display configuration (icons for server-side rendering)
const STATUS_ICONS: Record<ProjectStatus, string> = {
active: "lucide:circle-check",
maintained: "lucide:wrench",
archived: "lucide:archive",
hidden: "lucide:eye-off",
};
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch }); const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
// Collect all icon identifiers for batch rendering // Collect all tag icon identifiers for batch rendering
const iconIds = new Set<string>(); const iconIds = new Set<string>();
// Add status icons
for (const icon of Object.values(STATUS_ICONS)) {
iconIds.add(icon);
}
// Add tag icons
for (const project of projects) { for (const project of projects) {
for (const tag of project.tags) { for (const tag of project.tags) {
if (tag.icon) { if (tag.icon) {
@@ -42,14 +23,6 @@ export const load: PageServerLoad = async ({ fetch }) => {
// Batch render all icons // Batch render all icons
const icons = await renderIconsBatch([...iconIds], { size: 12 }); const icons = await renderIconsBatch([...iconIds], { size: 12 });
// Build status icons map
const statusIcons: Record<ProjectStatus, string> = {
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 // Map icons back to project tags
const projectsWithIcons: ProjectWithTagIcons[] = projects.map((project) => ({ const projectsWithIcons: ProjectWithTagIcons[] = projects.map((project) => ({
...project, ...project,
@@ -61,6 +34,5 @@ export const load: PageServerLoad = async ({ fetch }) => {
return { return {
projects: projectsWithIcons, projects: projectsWithIcons,
statusIcons,
}; };
}; };
+49 -99
View File
@@ -1,16 +1,11 @@
<script lang="ts"> <script lang="ts">
import Button from "$lib/components/admin/Button.svelte"; import Button from "$lib/components/admin/Button.svelte";
import Table from "$lib/components/admin/Table.svelte"; import Table from "$lib/components/admin/Table.svelte";
import Modal from "$lib/components/admin/Modal.svelte";
import TagChip from "$lib/components/TagChip.svelte"; import TagChip from "$lib/components/TagChip.svelte";
import { deleteAdminProject } from "$lib/api"; import { goto } from "$app/navigation";
import { invalidateAll } from "$app/navigation";
import type { ProjectWithTagIcons } from "./+page.server"; import type { ProjectWithTagIcons } from "./+page.server";
import type { ProjectStatus } from "$lib/admin-types"; import type { ProjectStatus } from "$lib/admin-types";
import IconPlus from "~icons/lucide/plus"; import IconPlus from "~icons/lucide/plus";
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["admin", "projects"]);
// Status display configuration (colors match Badge component) // Status display configuration (colors match Badge component)
const STATUS_CONFIG: Record<ProjectStatus, { color: string; label: string }> = const STATUS_CONFIG: Record<ProjectStatus, { color: string; label: string }> =
@@ -24,62 +19,48 @@
interface Props { interface Props {
data: { data: {
projects: ProjectWithTagIcons[]; projects: ProjectWithTagIcons[];
statusIcons: Record<ProjectStatus, string>;
}; };
} }
let { data }: Props = $props(); let { data }: Props = $props();
let deleteModalOpen = $state(false);
let deleteTarget = $state<ProjectWithTagIcons | null>(null);
let deleteConfirmReady = $state(false);
let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
function initiateDelete(project: ProjectWithTagIcons) {
deleteTarget = project;
deleteConfirmReady = false;
// Enable confirm button after delay
deleteTimeout = setTimeout(() => {
deleteConfirmReady = true;
}, 2000);
deleteModalOpen = true;
}
function cancelDelete() {
if (deleteTimeout) {
clearTimeout(deleteTimeout);
}
deleteModalOpen = false;
deleteTarget = null;
deleteConfirmReady = false;
}
async function confirmDelete() {
if (!deleteTarget || !deleteConfirmReady) return;
try {
await deleteAdminProject(deleteTarget.id);
await invalidateAll();
deleteModalOpen = false;
deleteTarget = null;
deleteConfirmReady = false;
} catch (error) {
logger.error("Failed to delete project", {
error: error instanceof Error ? error.message : String(error),
});
alert("Failed to delete project");
}
}
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString("en-US", { const now = new Date();
year: "numeric", const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
// Recent: relative timestamps
if (diffMins < 1) return "just now";
if (diffMins === 1) return "1 minute ago";
if (diffMins < 60) return `${diffMins} minutes ago`;
if (diffHours === 1) return "1 hour ago";
if (diffHours < 24) return `${diffHours} hours ago`;
// Yesterday: relative with time
if (diffDays === 1 || (diffHours >= 24 && diffHours < 48)) {
return `Yesterday at ${timeStr}`;
}
// Older: absolute timestamp with time
const dateOptions: Intl.DateTimeFormatOptions = {
month: "short", month: "short",
day: "numeric", day: "numeric",
}); };
// Only show year if different from current year
if (date.getFullYear() !== now.getFullYear()) {
dateOptions.year = "numeric";
}
const datePartStr = date.toLocaleDateString("en-US", dateOptions);
return `${datePartStr} at ${timeStr}`;
} }
</script> </script>
@@ -134,16 +115,19 @@
> >
Last Activity Last Activity
</th> </th>
<th
class="px-4 py-3 text-right text-xs font-medium text-admin-text-muted"
>
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-admin-border"> <tbody class="divide-y divide-admin-border">
{#each data.projects as project (project.id)} {#each data.projects as project (project.id)}
<tr class="hover:bg-admin-surface-hover/50 transition-colors"> <tr
class="hover:bg-admin-surface-hover/50 transition-colors cursor-pointer"
onclick={() => goto(`/admin/projects/${project.id}`)}
onkeydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
goto(`/admin/projects/${project.id}`)}
role="link"
tabindex="0"
>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div> <div>
@@ -160,11 +144,15 @@
<TagChip <TagChip
name={STATUS_CONFIG[project.status].label} name={STATUS_CONFIG[project.status].label}
color={STATUS_CONFIG[project.status].color} color={STATUS_CONFIG[project.status].color}
iconSvg={data.statusIcons[project.status]}
/> />
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex flex-wrap gap-1"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-wrap gap-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#each project.tags.slice(0, 3) as tag (tag.id)} {#each project.tags.slice(0, 3) as tag (tag.id)}
<TagChip <TagChip
name={tag.name} name={tag.name}
@@ -185,47 +173,9 @@
<td class="px-4 py-3 text-admin-text-secondary text-sm"> <td class="px-4 py-3 text-admin-text-secondary text-sm">
{formatDate(project.lastActivity)} {formatDate(project.lastActivity)}
</td> </td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-2">
<Button
variant="secondary"
size="sm"
href={`/admin/projects/${project.id}`}
>
Edit
</Button>
<Button
variant="danger"
size="sm"
onclick={() => initiateDelete(project)}
>
Delete
</Button>
</div>
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</Table> </Table>
{/if} {/if}
</div> </div>
<!-- Delete Confirmation Modal -->
<Modal
bind:open={deleteModalOpen}
title="Delete Project"
description="Are you sure you want to delete this project? This action cannot be undone."
confirmText={deleteConfirmReady ? "Delete" : `Wait ${2}s...`}
confirmVariant="danger"
onconfirm={confirmDelete}
oncancel={cancelDelete}
>
{#if deleteTarget}
<div
class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3"
>
<p class="font-medium text-admin-text">{deleteTarget.name}</p>
<p class="text-sm text-admin-text-secondary">{deleteTarget.slug}</p>
</div>
{/if}
</Modal>
@@ -2,12 +2,16 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProjectForm from "$lib/components/admin/ProjectForm.svelte"; 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 { import type {
UpdateProjectData, UpdateProjectData,
CreateProjectData, CreateProjectData,
TagWithIcon, TagWithIcon,
} from "$lib/admin-types"; } from "$lib/admin-types";
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["admin", "projects", "edit"]);
interface Props { interface Props {
data: { data: {
@@ -18,6 +22,38 @@
let { data }: Props = $props(); let { data }: Props = $props();
// Delete modal state
let deleteModalOpen = $state(false);
let deleteConfirmReady = $state(false);
let deleteTimeout: ReturnType<typeof setTimeout> | 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) { async function handleSubmit(formData: CreateProjectData) {
if (!data.project) return; if (!data.project) return;
@@ -60,8 +96,29 @@
project={data.project} project={data.project}
availableTags={data.availableTags} availableTags={data.availableTags}
onsubmit={handleSubmit} onsubmit={handleSubmit}
ondelete={initiateDelete}
submitLabel="Update Project" submitLabel="Update Project"
/> />
</div> </div>
{/if} {/if}
</div> </div>
<!-- Delete Confirmation Modal -->
<Modal
bind:open={deleteModalOpen}
title="Delete Project"
description="Are you sure you want to delete this project? This action cannot be undone."
confirmText={deleteConfirmReady ? "Delete" : "Wait 2s..."}
confirmVariant="danger"
onconfirm={confirmDelete}
oncancel={cancelDelete}
>
{#if data.project}
<div
class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3"
>
<p class="font-medium text-admin-text">{data.project.name}</p>
<p class="text-sm text-admin-text-secondary">{data.project.slug}</p>
</div>
{/if}
</Modal>