mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 02:26:38 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
availableTags: TagWithIcon[];
|
||||
onsubmit: (data: CreateProjectData) => Promise<void>;
|
||||
ondelete?: () => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@
|
||||
project = null,
|
||||
availableTags,
|
||||
onsubmit,
|
||||
ondelete,
|
||||
submitLabel = "Save Project",
|
||||
}: Props = $props();
|
||||
|
||||
@@ -182,10 +184,17 @@
|
||||
<MediaManager projectId={project?.id ?? null} media={project?.media ?? []} />
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border">
|
||||
<Button variant="secondary" href="/admin/projects">Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={submitting || !name}>
|
||||
{submitting ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
<div class="flex justify-between gap-3 pt-4 border-t border-admin-border">
|
||||
{#if ondelete}
|
||||
<Button variant="danger" onclick={ondelete}>Delete</Button>
|
||||
{:else}
|
||||
<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>
|
||||
</form>
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { telemetry } from "$lib/telemetry";
|
||||
|
||||
class AuthStore {
|
||||
private static STORAGE_KEY = "admin_session_active";
|
||||
|
||||
isAuthenticated = $state(false);
|
||||
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> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<Dots style="view-transition-name: background" />
|
||||
{/if}
|
||||
|
||||
<!-- Theme toggle - persistent across page transitions -->
|
||||
<!-- Header buttons - persistent across page transitions -->
|
||||
<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"
|
||||
>
|
||||
<AdminButton />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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<AdminProject, "tags"> {
|
||||
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 }) => {
|
||||
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>();
|
||||
|
||||
// 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<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
|
||||
const projectsWithIcons: ProjectWithTagIcons[] = projects.map((project) => ({
|
||||
...project,
|
||||
@@ -61,6 +34,5 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
|
||||
return {
|
||||
projects: projectsWithIcons,
|
||||
statusIcons,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/admin/Button.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 { deleteAdminProject } from "$lib/api";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { ProjectWithTagIcons } from "./+page.server";
|
||||
import type { ProjectStatus } from "$lib/admin-types";
|
||||
import IconPlus from "~icons/lucide/plus";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
|
||||
const logger = getLogger(["admin", "projects"]);
|
||||
|
||||
// Status display configuration (colors match Badge component)
|
||||
const STATUS_CONFIG: Record<ProjectStatus, { color: string; label: string }> =
|
||||
@@ -24,62 +19,48 @@
|
||||
interface Props {
|
||||
data: {
|
||||
projects: ProjectWithTagIcons[];
|
||||
statusIcons: Record<ProjectStatus, string>;
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
const now = new Date();
|
||||
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",
|
||||
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>
|
||||
|
||||
@@ -134,16 +115,19 @@
|
||||
>
|
||||
Last Activity
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-medium text-admin-text-muted"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-admin-border">
|
||||
{#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">
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
@@ -160,11 +144,15 @@
|
||||
<TagChip
|
||||
name={STATUS_CONFIG[project.status].label}
|
||||
color={STATUS_CONFIG[project.status].color}
|
||||
iconSvg={data.statusIcons[project.status]}
|
||||
/>
|
||||
</td>
|
||||
<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)}
|
||||
<TagChip
|
||||
name={tag.name}
|
||||
@@ -185,47 +173,9 @@
|
||||
<td class="px-4 py-3 text-admin-text-secondary text-sm">
|
||||
{formatDate(project.lastActivity)}
|
||||
</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>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
</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 { 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<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) {
|
||||
if (!data.project) return;
|
||||
|
||||
@@ -60,8 +96,29 @@
|
||||
project={data.project}
|
||||
availableTags={data.availableTags}
|
||||
onsubmit={handleSubmit}
|
||||
ondelete={initiateDelete}
|
||||
submitLabel="Update Project"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user