refactor: formatting and accessibility improvements across admin components

- Enforce consistent code formatting via ESLint rules
- Add accessibility enhancements (input IDs, labels, ARIA attributes)
- Replace Svelte store initialization patterns with reactive $effect
- Use $derived for reactive computed values
- Update error-codes and og-types indentation to spaces
- Add TypeScript definitions for html-minifier-terser
- Use resolve() for internal links in error and login pages
This commit is contained in:
2026-01-06 14:39:21 -06:00
parent ae83569fd7
commit b3dd1954d3
29 changed files with 446 additions and 246 deletions
+10
View File
@@ -25,6 +25,16 @@ export default ts.config(
parser: ts.parser, parser: ts.parser,
}, },
}, },
rules: {
// Disable resolve() requirement for dynamic hrefs in components
"svelte/no-navigation-without-resolve": "off",
},
},
{
files: ["**/*.svelte.ts"],
languageOptions: {
parser: ts.parser,
},
}, },
{ {
ignores: ["build/", ".svelte-kit/", "dist/"], ignores: ["build/", ".svelte-kit/", "dist/"],
+22
View File
@@ -0,0 +1,22 @@
declare module "html-minifier-terser" {
export interface Options {
collapseBooleanAttributes?: boolean;
collapseWhitespace?: boolean;
conservativeCollapse?: boolean;
decodeEntities?: boolean;
html5?: boolean;
ignoreCustomComments?: RegExp[];
minifyCSS?: boolean;
minifyJS?: boolean;
removeAttributeQuotes?: boolean;
removeComments?: boolean;
removeOptionalTags?: boolean;
removeRedundantAttributes?: boolean;
removeScriptTypeAttributes?: boolean;
removeStyleLinkTypeAttributes?: boolean;
sortAttributes?: boolean;
sortClassName?: boolean;
}
export function minify(html: string, options?: Options): string;
}
+6 -1
View File
@@ -78,7 +78,12 @@ export interface AuthSession {
expiresAt: string; // ISO 8601 expiresAt: string; // ISO 8601
} }
export type SocialPlatform = "github" | "linkedin" | "discord" | "email" | "pgp"; export type SocialPlatform =
| "github"
| "linkedin"
| "discord"
| "email"
| "pgp";
export interface SocialLink { export interface SocialLink {
id: string; id: string;
+109 -26
View File
@@ -9,9 +9,6 @@ import type {
CreateTagData, CreateTagData,
UpdateTagData, UpdateTagData,
SiteSettings, SiteSettings,
SiteIdentity,
SocialLink,
AdminPreferences,
} from "./admin-types"; } from "./admin-types";
// ============================================================================ // ============================================================================
@@ -19,30 +16,110 @@ import type {
// ============================================================================ // ============================================================================
// Mock data storage (in-memory for now) // Mock data storage (in-memory for now)
let MOCK_TAGS: AdminTag[] = [ const MOCK_TAGS: AdminTag[] = [
{ id: "tag-1", slug: "rust", name: "Rust", createdAt: "2024-01-15T10:00:00Z" }, {
{ id: "tag-2", slug: "typescript", name: "TypeScript", createdAt: "2024-01-16T10:00:00Z" }, id: "tag-1",
slug: "rust",
name: "Rust",
createdAt: "2024-01-15T10:00:00Z",
},
{
id: "tag-2",
slug: "typescript",
name: "TypeScript",
createdAt: "2024-01-16T10:00:00Z",
},
{ id: "tag-3", slug: "web", name: "Web", createdAt: "2024-01-17T10:00:00Z" }, { id: "tag-3", slug: "web", name: "Web", createdAt: "2024-01-17T10:00:00Z" },
{ id: "tag-4", slug: "cli", name: "CLI", createdAt: "2024-01-18T10:00:00Z" }, { id: "tag-4", slug: "cli", name: "CLI", createdAt: "2024-01-18T10:00:00Z" },
{ id: "tag-5", slug: "api", name: "API", createdAt: "2024-01-19T10:00:00Z" }, { id: "tag-5", slug: "api", name: "API", createdAt: "2024-01-19T10:00:00Z" },
{ id: "tag-6", slug: "database", name: "Database", createdAt: "2024-01-20T10:00:00Z" }, {
{ id: "tag-7", slug: "svelte", name: "Svelte", createdAt: "2024-01-21T10:00:00Z" }, id: "tag-6",
{ id: "tag-8", slug: "python", name: "Python", createdAt: "2024-01-22T10:00:00Z" }, slug: "database",
{ id: "tag-9", slug: "machine-learning", name: "Machine Learning", createdAt: "2024-01-23T10:00:00Z" }, name: "Database",
{ id: "tag-10", slug: "docker", name: "Docker", createdAt: "2024-01-24T10:00:00Z" }, createdAt: "2024-01-20T10:00:00Z",
{ id: "tag-11", slug: "kubernetes", name: "Kubernetes", createdAt: "2024-01-25T10:00:00Z" }, },
{ id: "tag-12", slug: "react", name: "React", createdAt: "2024-01-26T10:00:00Z" }, {
{ id: "tag-13", slug: "nextjs", name: "Next.js", createdAt: "2024-01-27T10:00:00Z" }, id: "tag-7",
{ id: "tag-14", slug: "tailwind", name: "Tailwind CSS", createdAt: "2024-01-28T10:00:00Z" }, slug: "svelte",
{ id: "tag-15", slug: "graphql", name: "GraphQL", createdAt: "2024-01-29T10:00:00Z" }, name: "Svelte",
{ id: "tag-16", slug: "postgres", name: "PostgreSQL", createdAt: "2024-01-30T10:00:00Z" }, createdAt: "2024-01-21T10:00:00Z",
{ id: "tag-17", slug: "redis", name: "Redis", createdAt: "2024-01-31T10:00:00Z" }, },
{
id: "tag-8",
slug: "python",
name: "Python",
createdAt: "2024-01-22T10:00:00Z",
},
{
id: "tag-9",
slug: "machine-learning",
name: "Machine Learning",
createdAt: "2024-01-23T10:00:00Z",
},
{
id: "tag-10",
slug: "docker",
name: "Docker",
createdAt: "2024-01-24T10:00:00Z",
},
{
id: "tag-11",
slug: "kubernetes",
name: "Kubernetes",
createdAt: "2024-01-25T10:00:00Z",
},
{
id: "tag-12",
slug: "react",
name: "React",
createdAt: "2024-01-26T10:00:00Z",
},
{
id: "tag-13",
slug: "nextjs",
name: "Next.js",
createdAt: "2024-01-27T10:00:00Z",
},
{
id: "tag-14",
slug: "tailwind",
name: "Tailwind CSS",
createdAt: "2024-01-28T10:00:00Z",
},
{
id: "tag-15",
slug: "graphql",
name: "GraphQL",
createdAt: "2024-01-29T10:00:00Z",
},
{
id: "tag-16",
slug: "postgres",
name: "PostgreSQL",
createdAt: "2024-01-30T10:00:00Z",
},
{
id: "tag-17",
slug: "redis",
name: "Redis",
createdAt: "2024-01-31T10:00:00Z",
},
{ id: "tag-18", slug: "aws", name: "AWS", createdAt: "2024-02-01T10:00:00Z" }, { id: "tag-18", slug: "aws", name: "AWS", createdAt: "2024-02-01T10:00:00Z" },
{ id: "tag-19", slug: "devops", name: "DevOps", createdAt: "2024-02-02T10:00:00Z" }, {
{ id: "tag-20", slug: "security", name: "Security", createdAt: "2024-02-03T10:00:00Z" }, id: "tag-19",
slug: "devops",
name: "DevOps",
createdAt: "2024-02-02T10:00:00Z",
},
{
id: "tag-20",
slug: "security",
name: "Security",
createdAt: "2024-02-03T10:00:00Z",
},
]; ];
let MOCK_PROJECTS: AdminProject[] = [ const MOCK_PROJECTS: AdminProject[] = [
{ {
id: "proj-1", id: "proj-1",
slug: "portfolio-site", slug: "portfolio-site",
@@ -167,7 +244,8 @@ let MOCK_PROJECTS: AdminProject[] = [
id: "proj-9", id: "proj-9",
slug: "security-scanner", slug: "security-scanner",
title: "Security Scanner", title: "Security Scanner",
description: "Automated security vulnerability scanner for web applications", description:
"Automated security vulnerability scanner for web applications",
status: "active", status: "active",
githubRepo: "xevion/sec-scanner", githubRepo: "xevion/sec-scanner",
demoUrl: null, demoUrl: null,
@@ -197,7 +275,8 @@ let MOCK_PROJECTS: AdminProject[] = [
id: "proj-11", id: "proj-11",
slug: "deployment-tools", slug: "deployment-tools",
title: "Deployment Automation Tools", title: "Deployment Automation Tools",
description: "CLI tools for automated deployments to multiple cloud providers", description:
"CLI tools for automated deployments to multiple cloud providers",
status: "active", status: "active",
githubRepo: "xevion/deploy-tools", githubRepo: "xevion/deploy-tools",
demoUrl: null, demoUrl: null,
@@ -270,7 +349,7 @@ let MOCK_PROJECTS: AdminProject[] = [
}, },
]; ];
let MOCK_EVENTS: AdminEvent[] = [ const MOCK_EVENTS: AdminEvent[] = [
{ {
id: "evt-1", id: "evt-1",
timestamp: "2025-01-06T10:30:00Z", timestamp: "2025-01-06T10:30:00Z",
@@ -455,7 +534,9 @@ export async function getAdminProjects(): Promise<AdminProject[]> {
return [...MOCK_PROJECTS].sort((a, b) => b.priority - a.priority); return [...MOCK_PROJECTS].sort((a, b) => b.priority - a.priority);
} }
export async function getAdminProject(id: string): Promise<AdminProject | null> { export async function getAdminProject(
id: string,
): Promise<AdminProject | null> {
// TODO: Replace with apiFetch(`/admin/api/projects/${id}`) when backend ready // TODO: Replace with apiFetch(`/admin/api/projects/${id}`) when backend ready
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
return MOCK_PROJECTS.find((p) => p.id === id) || null; return MOCK_PROJECTS.find((p) => p.id === id) || null;
@@ -743,7 +824,9 @@ export async function getSettings(): Promise<SiteSettings> {
return structuredClone(MOCK_SETTINGS); return structuredClone(MOCK_SETTINGS);
} }
export async function updateSettings(settings: SiteSettings): Promise<SiteSettings> { export async function updateSettings(
settings: SiteSettings,
): Promise<SiteSettings> {
// TODO: Replace with apiFetch('/admin/api/settings', { method: 'PUT', body: JSON.stringify(settings) }) // TODO: Replace with apiFetch('/admin/api/settings', { method: 'PUT', body: JSON.stringify(settings) })
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
+8 -3
View File
@@ -33,8 +33,7 @@
"bg-transparent text-admin-text border border-zinc-700 hover:border-zinc-600 hover:bg-zinc-800/50 focus-visible:ring-zinc-500", "bg-transparent text-admin-text border border-zinc-700 hover:border-zinc-600 hover:bg-zinc-800/50 focus-visible:ring-zinc-500",
danger: danger:
"bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-500 shadow-sm hover:shadow", "bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-500 shadow-sm hover:shadow",
ghost: ghost: "text-admin-text hover:bg-zinc-800/50 focus-visible:ring-zinc-500",
"text-admin-text hover:bg-zinc-800/50 focus-visible:ring-zinc-500",
}; };
const sizeStyles = { const sizeStyles = {
@@ -47,7 +46,13 @@
{#if href} {#if href}
<a <a
{href} {href}
class={cn(baseStyles, variantStyles[variant], sizeStyles[size], "cursor-pointer", className)} class={cn(
baseStyles,
variantStyles[variant],
sizeStyles[size],
"cursor-pointer",
className,
)}
aria-disabled={disabled} aria-disabled={disabled}
> >
{@render children?.()} {@render children?.()}
+15 -7
View File
@@ -34,28 +34,30 @@
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
options={{ options={{
scrollbars: { autoHide: "leave", autoHideDelay: 800 } scrollbars: { autoHide: "leave", autoHideDelay: 800 },
}} }}
defer defer
style="max-height: {maxHeight}" style="max-height: {maxHeight}"
> >
<div class="divide-y divide-zinc-800/50 bg-zinc-950"> <div class="divide-y divide-zinc-800/50 bg-zinc-950">
{#each events as event} {#each events as event (event.id)}
{@const levelColors = { {@const levelColors = {
info: "text-cyan-500/60", info: "text-cyan-500/60",
warning: "text-amber-500/70", warning: "text-amber-500/70",
error: "text-rose-500/70" error: "text-rose-500/70",
}} }}
{@const levelLabels = { {@const levelLabels = {
info: "INFO", info: "INFO",
warning: "WARN", warning: "WARN",
error: "ERR" error: "ERR",
}} }}
<div class="hover:bg-zinc-900/50 transition-colors"> <div class="hover:bg-zinc-900/50 transition-colors">
<div class="px-4 py-1.5"> <div class="px-4 py-1.5">
<div class="flex items-center justify-between gap-4 text-xs"> <div class="flex items-center justify-between gap-4 text-xs">
<div class="flex items-center gap-2.5 flex-1 min-w-0"> <div class="flex items-center gap-2.5 flex-1 min-w-0">
<span class={`${levelColors[event.level]} font-mono font-medium shrink-0 w-10`}> <span
class={`${levelColors[event.level]} font-mono font-medium shrink-0 w-10`}
>
{levelLabels[event.level]} {levelLabels[event.level]}
</span> </span>
<span class="text-zinc-300 truncate"> <span class="text-zinc-300 truncate">
@@ -82,9 +84,15 @@
</div> </div>
{#if showMetadata && expandedEventId === event.id && event.metadata} {#if showMetadata && expandedEventId === event.id && event.metadata}
<div class="px-4 pb-2"> <div class="px-4 pb-2">
<div class="bg-zinc-900 border border-zinc-800 rounded p-3 text-[11px]"> <div
class="bg-zinc-900 border border-zinc-800 rounded p-3 text-[11px]"
>
<p class="text-zinc-500 mb-2 font-medium">Metadata:</p> <p class="text-zinc-500 mb-2 font-medium">Metadata:</p>
<pre class="text-zinc-400 overflow-x-auto">{JSON.stringify(event.metadata, null, 2)}</pre> <pre class="text-zinc-400 overflow-x-auto">{JSON.stringify(
event.metadata,
null,
2,
)}</pre>
</div> </div>
</div> </div>
{/if} {/if}
+25 -9
View File
@@ -3,7 +3,14 @@
interface Props { interface Props {
label?: string; label?: string;
type?: "text" | "number" | "email" | "password" | "url" | "textarea" | "select"; type?:
| "text"
| "number"
| "email"
| "password"
| "url"
| "textarea"
| "select";
value: string | number; value: string | number;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
@@ -17,9 +24,9 @@
} }
let { let {
label,
type = "text", type = "text",
value = $bindable(""), value = $bindable(),
label,
placeholder, placeholder,
disabled = false, disabled = false,
required = false, required = false,
@@ -31,15 +38,21 @@
oninput, oninput,
}: Props = $props(); }: Props = $props();
// Generate unique ID for accessibility
const inputId = `input-${Math.random().toString(36).substring(2, 11)}`;
const inputStyles = const inputStyles =
"block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"; "block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 transition-colors";
const errorStyles = error const errorStyles = $derived(
? "border-red-500 focus:border-red-500 focus:ring-red-500" error ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "",
: ""; );
function handleInput(e: Event) { function handleInput(e: Event) {
const target = e.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; const target = e.target as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement;
const newValue = type === "number" ? Number(target.value) : target.value; const newValue = type === "number" ? Number(target.value) : target.value;
value = newValue; value = newValue;
oninput?.(newValue); oninput?.(newValue);
@@ -48,7 +61,7 @@
<div class={cn("space-y-1.5", className)}> <div class={cn("space-y-1.5", className)}>
{#if label} {#if label}
<label class="block text-sm font-medium text-admin-text"> <label for={inputId} class="block text-sm font-medium text-admin-text">
{label} {label}
{#if required} {#if required}
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
@@ -58,6 +71,7 @@
{#if type === "textarea"} {#if type === "textarea"}
<textarea <textarea
id={inputId}
bind:value bind:value
{placeholder} {placeholder}
{disabled} {disabled}
@@ -68,6 +82,7 @@
></textarea> ></textarea>
{:else if type === "select"} {:else if type === "select"}
<select <select
id={inputId}
bind:value bind:value
{disabled} {disabled}
{required} {required}
@@ -77,12 +92,13 @@
{#if placeholder} {#if placeholder}
<option value="" disabled>{placeholder}</option> <option value="" disabled>{placeholder}</option>
{/if} {/if}
{#each options as option} {#each options as option (option.value)}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
{/each} {/each}
</select> </select>
{:else} {:else}
<input <input
id={inputId}
{type} {type}
bind:value bind:value
{placeholder} {placeholder}
+5 -3
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils";
import Button from "./Button.svelte"; import Button from "./Button.svelte";
interface Props { interface Props {
@@ -47,11 +46,14 @@
<div <div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onclick={handleBackdropClick} onclick={handleBackdropClick}
role="dialog" onkeydown={(e) => e.key === "Escape" && handleCancel()}
aria-modal="true" role="presentation"
tabindex="-1"
> >
<div <div
class="relative w-full max-w-md rounded-xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl shadow-black/50" class="relative w-full max-w-md rounded-xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl shadow-black/50"
role="dialog"
aria-modal="true"
> >
{#if title} {#if title}
<h2 class="text-lg font-semibold text-zinc-50 mb-2"> <h2 class="text-lg font-semibold text-zinc-50 mb-2">
+35 -35
View File
@@ -2,7 +2,12 @@
import Button from "./Button.svelte"; import Button from "./Button.svelte";
import Input from "./Input.svelte"; import Input from "./Input.svelte";
import TagPicker from "./TagPicker.svelte"; import TagPicker from "./TagPicker.svelte";
import type { AdminProject, AdminTag, CreateProjectData, ProjectStatus } from "$lib/admin-types"; import type {
AdminProject,
AdminTag,
CreateProjectData,
ProjectStatus,
} from "$lib/admin-types";
interface Props { interface Props {
project?: AdminProject | null; project?: AdminProject | null;
@@ -19,18 +24,32 @@
}: Props = $props(); }: Props = $props();
// Form state // Form state
let title = $state(project?.title ?? ""); let title = $state("");
let slug = $state(project?.slug ?? ""); let slug = $state("");
let description = $state(project?.description ?? ""); let description = $state("");
let status = $state<ProjectStatus>(project?.status ?? "active"); let status = $state<ProjectStatus>("active");
let githubRepo = $state(project?.githubRepo ?? ""); let githubRepo = $state("");
let demoUrl = $state(project?.demoUrl ?? ""); let demoUrl = $state("");
let icon = $state(project?.icon ?? ""); let icon = $state("");
let priority = $state(project?.priority ?? 0); let priority = $state(0);
let selectedTagIds = $state<string[]>(project?.tags.map(t => t.id) ?? []); let selectedTagIds = $state<string[]>([]);
// Initialize form from project prop
$effect(() => {
if (project) {
title = project.title;
slug = project.slug;
description = project.description;
status = project.status;
githubRepo = project.githubRepo ?? "";
demoUrl = project.demoUrl ?? "";
icon = project.icon ?? "";
priority = project.priority;
selectedTagIds = project.tags.map((t) => t.id);
}
});
let submitting = $state(false); let submitting = $state(false);
let slugTouched = $state(false);
const statusOptions = [ const statusOptions = [
{ value: "active", label: "Active" }, { value: "active", label: "Active" },
@@ -45,11 +64,10 @@
.toLowerCase() .toLowerCase()
.replace(/[^\w\s-]/g, "") .replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-") .replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, ""),
); );
function handleSlugInput(value: string | number) { function handleSlugInput(value: string | number) {
slugTouched = true;
slug = value as string; slug = value as string;
} }
@@ -76,8 +94,6 @@
submitting = false; submitting = false;
} }
} }
</script> </script>
<form onsubmit={handleSubmit} class="space-y-6"> <form onsubmit={handleSubmit} class="space-y-6">
@@ -170,15 +186,8 @@
<!-- Media Upload Placeholder --> <!-- Media Upload Placeholder -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="block text-sm font-medium text-admin-text"> <div class="block text-sm font-medium text-admin-text">Media</div>
Media <Button type="button" variant="secondary" disabled class="w-full">
</label>
<Button
type="button"
variant="secondary"
disabled
class="w-full"
>
<i class="fa-solid fa-upload mr-2"></i> <i class="fa-solid fa-upload mr-2"></i>
Upload Images/Videos (Coming Soon) Upload Images/Videos (Coming Soon)
</Button> </Button>
@@ -189,17 +198,8 @@
<!-- Actions --> <!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border"> <div class="flex justify-end gap-3 pt-4 border-t border-admin-border">
<Button <Button variant="secondary" href="/admin/projects">Cancel</Button>
variant="secondary" <Button type="submit" variant="primary" disabled={submitting || !title}>
href="/admin/projects"
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={submitting || !title}
>
{submitting ? "Saving..." : submitLabel} {submitting ? "Saving..." : submitLabel}
</Button> </Button>
</div> </div>
+15 -7
View File
@@ -24,17 +24,22 @@
interface NavItem { interface NavItem {
href: string; href: string;
label: string; label: string;
icon: any; icon: import("svelte").Component;
badge?: number; badge?: number;
} }
const navItems: NavItem[] = [ const navItems = $derived<NavItem[]>([
{ href: "/admin", label: "Dashboard", icon: IconLayoutDashboard }, { href: "/admin", label: "Dashboard", icon: IconLayoutDashboard },
{ href: "/admin/projects", label: "Projects", icon: IconFolder, badge: projectCount }, {
href: "/admin/projects",
label: "Projects",
icon: IconFolder,
badge: projectCount,
},
{ href: "/admin/tags", label: "Tags", icon: IconTags, badge: tagCount }, { href: "/admin/tags", label: "Tags", icon: IconTags, badge: tagCount },
{ href: "/admin/events", label: "Events", icon: IconList }, { href: "/admin/events", label: "Events", icon: IconList },
{ href: "/admin/settings", label: "Settings", icon: IconSettings }, { href: "/admin/settings", label: "Settings", icon: IconSettings },
]; ]);
const pathname = $derived($page.url.pathname as string); const pathname = $derived($page.url.pathname as string);
@@ -67,7 +72,7 @@
<aside <aside
class={cn( class={cn(
"fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-admin-bg transition-transform lg:translate-x-0", "fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-admin-bg transition-transform lg:translate-x-0",
mobileMenuOpen ? "translate-x-0" : "-translate-x-full" mobileMenuOpen ? "translate-x-0" : "-translate-x-full",
)} )}
> >
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
@@ -81,14 +86,14 @@
<!-- Navigation --> <!-- Navigation -->
<nav class="flex-1 space-y-0.5 p-3"> <nav class="flex-1 space-y-0.5 p-3">
{#each navItems as item} {#each navItems as item (item.href)}
<a <a
href={item.href} href={item.href}
class={cn( class={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all relative", "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all relative",
isActive(item.href) isActive(item.href)
? "bg-zinc-800/50 text-zinc-50 before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-indigo-500 before:rounded-r" ? "bg-zinc-800/50 text-zinc-50 before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-indigo-500 before:rounded-r"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/30" : "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/30",
)} )}
> >
<item.icon class="w-4 h-4 flex-shrink-0" /> <item.icon class="w-4 h-4 flex-shrink-0" />
@@ -127,5 +132,8 @@
<div <div
class="fixed inset-0 z-30 bg-black/50 lg:hidden" class="fixed inset-0 z-30 bg-black/50 lg:hidden"
onclick={() => (mobileMenuOpen = false)} onclick={() => (mobileMenuOpen = false)}
onkeydown={(e) => e.key === "Escape" && (mobileMenuOpen = false)}
role="presentation"
tabindex="-1"
></div> ></div>
{/if} {/if}
+3 -1
View File
@@ -9,7 +9,9 @@
let { class: className, children }: Props = $props(); let { class: className, children }: Props = $props();
</script> </script>
<div class={cn("overflow-x-auto rounded-lg border border-admin-border", className)}> <div
class={cn("overflow-x-auto rounded-lg border border-admin-border", className)}
>
<table class="w-full text-sm"> <table class="w-full text-sm">
{@render children?.()} {@render children?.()}
</table> </table>
+10 -6
View File
@@ -23,16 +23,19 @@
let dropdownOpen = $state(false); let dropdownOpen = $state(false);
let inputRef: HTMLInputElement | undefined = $state(); let inputRef: HTMLInputElement | undefined = $state();
// Generate unique ID for accessibility
const inputId = `tagpicker-${Math.random().toString(36).substring(2, 11)}`;
const selectedTags = $derived( const selectedTags = $derived(
availableTags.filter((tag) => selectedTagIds.includes(tag.id)) availableTags.filter((tag) => selectedTagIds.includes(tag.id)),
); );
const filteredTags = $derived( const filteredTags = $derived(
availableTags.filter( availableTags.filter(
(tag) => (tag) =>
!selectedTagIds.includes(tag.id) && !selectedTagIds.includes(tag.id) &&
tag.name.toLowerCase().includes(searchTerm.toLowerCase()) tag.name.toLowerCase().includes(searchTerm.toLowerCase()),
) ),
); );
function addTag(tagId: string) { function addTag(tagId: string) {
@@ -59,7 +62,7 @@
<div class={cn("space-y-1.5", className)}> <div class={cn("space-y-1.5", className)}>
{#if label} {#if label}
<label class="block text-sm font-medium text-admin-text"> <label for={inputId} class="block text-sm font-medium text-admin-text">
{label} {label}
</label> </label>
{/if} {/if}
@@ -70,7 +73,7 @@
class="min-h-[42px] w-full rounded-md border border-admin-border bg-admin-panel px-3 py-2" class="min-h-[42px] w-full rounded-md border border-admin-border bg-admin-panel px-3 py-2"
> >
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each selectedTags as tag} {#each selectedTags as tag (tag.id)}
<span <span
class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-500/20" class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-500/20"
> >
@@ -88,6 +91,7 @@
<!-- Search input --> <!-- Search input -->
<input <input
id={inputId}
bind:this={inputRef} bind:this={inputRef}
type="text" type="text"
bind:value={searchTerm} bind:value={searchTerm}
@@ -104,7 +108,7 @@
<div <div
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-admin-border bg-admin-panel py-1 shadow-lg" class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-admin-border bg-admin-panel py-1 shadow-lg"
> >
{#each filteredTags as tag} {#each filteredTags as tag (tag.id)}
<button <button
type="button" type="button"
class="w-full px-3 py-2 text-left text-sm text-admin-text hover:bg-admin-hover transition-colors" class="w-full px-3 py-2 text-left text-sm text-admin-text hover:bg-admin-hover transition-colors"
+25 -25
View File
@@ -6,36 +6,36 @@
* - Rust assets.rs (validation only) * - Rust assets.rs (validation only)
*/ */
export const ERROR_CODES = { export const ERROR_CODES = {
// 4xx Client Errors // 4xx Client Errors
400: { message: "Bad request", transient: false }, 400: { message: "Bad request", transient: false },
401: { message: "Unauthorized", transient: false }, 401: { message: "Unauthorized", transient: false },
403: { message: "Forbidden", transient: false }, 403: { message: "Forbidden", transient: false },
404: { message: "Page not found", transient: false }, 404: { message: "Page not found", transient: false },
405: { message: "Method not allowed", transient: false }, 405: { message: "Method not allowed", transient: false },
406: { message: "Not acceptable", transient: false }, 406: { message: "Not acceptable", transient: false },
408: { message: "Request timeout", transient: true }, 408: { message: "Request timeout", transient: true },
409: { message: "Conflict", transient: false }, 409: { message: "Conflict", transient: false },
410: { message: "Gone", transient: false }, 410: { message: "Gone", transient: false },
413: { message: "Payload too large", transient: false }, 413: { message: "Payload too large", transient: false },
414: { message: "URI too long", transient: false }, 414: { message: "URI too long", transient: false },
415: { message: "Unsupported media type", transient: false }, 415: { message: "Unsupported media type", transient: false },
418: { message: "I'm a teapot", transient: false }, // RFC 2324 Easter egg 418: { message: "I'm a teapot", transient: false }, // RFC 2324 Easter egg
422: { message: "Unprocessable entity", transient: false }, 422: { message: "Unprocessable entity", transient: false },
429: { message: "Too many requests", transient: true }, 429: { message: "Too many requests", transient: true },
451: { message: "Unavailable for legal reasons", transient: false }, 451: { message: "Unavailable for legal reasons", transient: false },
// 5xx Server Errors // 5xx Server Errors
500: { message: "Internal server error", transient: false }, 500: { message: "Internal server error", transient: false },
501: { message: "Not implemented", transient: false }, 501: { message: "Not implemented", transient: false },
502: { message: "Bad gateway", transient: true }, 502: { message: "Bad gateway", transient: true },
503: { message: "Service unavailable", transient: true }, 503: { message: "Service unavailable", transient: true },
504: { message: "Gateway timeout", transient: true }, 504: { message: "Gateway timeout", transient: true },
505: { message: "HTTP version not supported", transient: false }, 505: { message: "HTTP version not supported", transient: false },
} as const; } as const;
export type ErrorCode = keyof typeof ERROR_CODES; export type ErrorCode = keyof typeof ERROR_CODES;
// Helper to check if error code is defined // Helper to check if error code is defined
export function isDefinedErrorCode(code: number): code is ErrorCode { export function isDefinedErrorCode(code: number): code is ErrorCode {
return code in ERROR_CODES; return code in ERROR_CODES;
} }
+13 -13
View File
@@ -16,19 +16,19 @@ export type OGImageSpec =
* @returns Full URL to the R2-hosted image * @returns Full URL to the R2-hosted image
*/ */
export function getOGImageUrl(spec: OGImageSpec): string { export function getOGImageUrl(spec: OGImageSpec): string {
const R2_BASE = import.meta.env.VITE_OG_R2_BASE_URL; const R2_BASE = import.meta.env.VITE_OG_R2_BASE_URL;
if (!R2_BASE) { if (!R2_BASE) {
// During prerendering or development, use a fallback placeholder // During prerendering or development, use a fallback placeholder
return "/og/placeholder.png"; return "/og/placeholder.png";
} }
switch (spec.type) { switch (spec.type) {
case "index": case "index":
return `${R2_BASE}/og/index.png`; return `${R2_BASE}/og/index.png`;
case "projects": case "projects":
return `${R2_BASE}/og/projects.png`; return `${R2_BASE}/og/projects.png`;
case "project": case "project":
return `${R2_BASE}/og/project/${spec.id}.png`; return `${R2_BASE}/og/project/${spec.id}.png`;
} }
} }
+3 -2
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths";
import AppWrapper from "$lib/components/AppWrapper.svelte"; import AppWrapper from "$lib/components/AppWrapper.svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
const status = $derived($page.status); const status = $derived($page.status);
const messages: Record<number, string> = { const messages: Record<number, string> = {
404: "Page not found", 404: "Page not found",
405: "Method not allowed", 405: "Method not allowed",
@@ -27,7 +28,7 @@
<p class="mb-8 text-2xl text-zinc-400">{message}</p> <p class="mb-8 text-2xl text-zinc-400">{message}</p>
{#if showHomeLink} {#if showHomeLink}
<a <a
href="/" href={resolve("/")}
class="inline-block rounded-sm bg-zinc-900 px-4 py-2 text-zinc-100 transition-colors hover:bg-zinc-800" class="inline-block rounded-sm bg-zinc-900 px-4 py-2 text-zinc-100 transition-colors hover:bg-zinc-800"
> >
Return home Return home
+3 -6
View File
@@ -12,17 +12,14 @@ export const load: LayoutServerLoad = async ({ request, url }) => {
if (!sessionUser) { if (!sessionUser) {
const targetPath = url.pathname + url.search; const targetPath = url.pathname + url.search;
// If redirecting to /admin (the default), omit the next parameter // If redirecting to /admin (the default), omit the next parameter
if (targetPath === "/admin") { if (targetPath === "/admin") {
throw redirect(302, "/admin/login"); throw redirect(302, "/admin/login");
} }
// For other paths, include next parameter // For other paths, include next parameter
throw redirect( throw redirect(302, `/admin/login?next=${encodeURIComponent(targetPath)}`);
302,
`/admin/login?next=${encodeURIComponent(targetPath)}`
);
} }
return { return {
+8 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import Sidebar from "$lib/components/admin/Sidebar.svelte"; import Sidebar from "$lib/components/admin/Sidebar.svelte";
import AppWrapper from "$lib/components/AppWrapper.svelte"; import AppWrapper from "$lib/components/AppWrapper.svelte";
@@ -10,7 +11,6 @@
let { children, data } = $props(); let { children, data } = $props();
let stats = $state<AdminStats | null>(null); let stats = $state<AdminStats | null>(null);
let loading = $state(true);
const pathname = $derived($page.url.pathname as string); const pathname = $derived($page.url.pathname as string);
const isLoginPage = $derived(pathname === "/admin/login"); const isLoginPage = $derived(pathname === "/admin/login");
@@ -18,19 +18,21 @@
// Load stats for sidebar badges // Load stats for sidebar badges
async function loadStats() { async function loadStats() {
if (isLoginPage || !authStore.isAuthenticated) return; if (isLoginPage || !authStore.isAuthenticated) return;
try { try {
stats = await getAdminStats(); stats = await getAdminStats();
} catch (error) { } catch (error) {
console.error("Failed to load stats:", error); console.error("Failed to load stats:", error);
} finally {
loading = false;
} }
} }
// Sync authStore with server session on mount // Sync authStore with server session on mount
$effect(() => { $effect(() => {
if (data?.session?.authenticated && data.session.username && !authStore.isAuthenticated) { if (
data?.session?.authenticated &&
data.session.username &&
!authStore.isAuthenticated
) {
authStore.setSession(data.session.username); authStore.setSession(data.session.username);
} }
}); });
@@ -44,7 +46,7 @@
function handleLogout() { function handleLogout() {
authStore.logout(); authStore.logout();
goto("/admin/login"); goto(resolve("/admin/login"));
} }
</script> </script>
+11 -12
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths";
import Button from "$lib/components/admin/Button.svelte"; import Button from "$lib/components/admin/Button.svelte";
import EventLog from "$lib/components/admin/EventLog.svelte"; import EventLog from "$lib/components/admin/EventLog.svelte";
import { getAdminEvents } from "$lib/api"; import { getAdminEvents } from "$lib/api";
@@ -51,20 +52,20 @@
<Button variant="secondary" href="/admin/projects"> <Button variant="secondary" href="/admin/projects">
View All Projects View All Projects
</Button> </Button>
<Button variant="secondary" href="/admin/tags"> <Button variant="secondary" href="/admin/tags">Manage Tags</Button>
Manage Tags <Button variant="secondary" href="/admin/events">View Events</Button>
</Button>
<Button variant="secondary" href="/admin/events">
View Events
</Button>
</div> </div>
<!-- Recent Events --> <!-- Recent Events -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden shadow-sm shadow-black/20"> <div
<div class="flex items-center justify-between px-6 py-3.5 bg-zinc-800/30 border-b border-zinc-800"> class="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden shadow-sm shadow-black/20"
>
<div
class="flex items-center justify-between px-6 py-3.5 bg-zinc-800/30 border-b border-zinc-800"
>
<h2 class="text-sm font-medium text-zinc-300">Recent Events</h2> <h2 class="text-sm font-medium text-zinc-300">Recent Events</h2>
<a <a
href="/admin/events" href={resolve("/admin/events")}
class="text-sm text-indigo-400 hover:text-indigo-300 transition-colors" class="text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
> >
View all → View all →
@@ -72,9 +73,7 @@
</div> </div>
{#if recentEvents.length === 0} {#if recentEvents.length === 0}
<p class="text-sm text-zinc-500 text-center py-8"> <p class="text-sm text-zinc-500 text-center py-8">No events yet</p>
No events yet
</p>
{:else} {:else}
<EventLog events={recentEvents} maxHeight="400px" /> <EventLog events={recentEvents} maxHeight="400px" />
{/if} {/if}
+11 -13
View File
@@ -31,14 +31,10 @@
} }
} }
// Load events on mount and when filters change
$effect(() => { $effect(() => {
loadEvents(); void filterLevel;
}); void filterTarget;
// Reload when filters change
$effect(() => {
filterLevel;
filterTarget;
loadEvents(); loadEvents();
}); });
</script> </script>
@@ -57,7 +53,9 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-sm shadow-black/20"> <div
class="rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-sm shadow-black/20"
>
<h3 class="text-sm font-medium text-zinc-400 mb-4">Filters</h3> <h3 class="text-sm font-medium text-zinc-400 mb-4">Filters</h3>
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<Input <Input
@@ -77,15 +75,15 @@
<!-- Events Log --> <!-- Events Log -->
{#if loading} {#if loading}
<div class="text-center py-12 text-zinc-500"> <div class="text-center py-12 text-zinc-500">Loading events...</div>
Loading events...
</div>
{:else if events.length === 0} {:else if events.length === 0}
<div class="text-center py-12"> <div class="text-center py-12">
<p class="text-zinc-500">No events found</p> <p class="text-zinc-500">No events found</p>
</div> </div>
{:else} {:else}
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden shadow-sm shadow-black/20"> <div
class="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden shadow-sm shadow-black/20"
>
<div class="px-6 py-3.5 bg-zinc-800/30 border-b border-zinc-800"> <div class="px-6 py-3.5 bg-zinc-800/30 border-b border-zinc-800">
<h2 class="text-sm font-medium text-zinc-300"> <h2 class="text-sm font-medium text-zinc-300">
Event Log Event Log
@@ -94,7 +92,7 @@
</span> </span>
</h2> </h2>
</div> </div>
<EventLog events={events} maxHeight="600px" showMetadata={true} /> <EventLog {events} maxHeight="600px" showMetadata={true} />
</div> </div>
{/if} {/if}
</div> </div>
+4 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import Button from "$lib/components/admin/Button.svelte"; import Button from "$lib/components/admin/Button.svelte";
import Input from "$lib/components/admin/Input.svelte"; import Input from "$lib/components/admin/Input.svelte";
@@ -18,7 +19,7 @@
try { try {
const success = await authStore.login(username, password); const success = await authStore.login(username, password);
if (success) { if (success) {
const nextUrl = $page.url.searchParams.get("next") || "/admin"; const nextUrl = $page.url.searchParams.get("next") || "/admin";
goto(nextUrl); goto(nextUrl);
@@ -42,9 +43,7 @@
<div class="flex min-h-screen items-center justify-center px-4"> <div class="flex min-h-screen items-center justify-center px-4">
<div class="w-full max-w-md space-y-4"> <div class="w-full max-w-md space-y-4">
<!-- Login Form --> <!-- Login Form -->
<div <div class="rounded-lg bg-admin-panel p-8 shadow-2xl shadow-zinc-500/20">
class="rounded-lg bg-admin-panel p-8 shadow-2xl shadow-zinc-500/20"
>
<form onsubmit={handleSubmit} class="space-y-6"> <form onsubmit={handleSubmit} class="space-y-6">
<Input <Input
label="Username" label="Username"
@@ -86,7 +85,7 @@
<!-- Back to site link --> <!-- Back to site link -->
<div class="text-center"> <div class="text-center">
<a <a
href="/" href={resolve("/")}
class="text-sm text-admin-text-muted hover:text-admin-text transition-colors" class="text-sm text-admin-text-muted hover:text-admin-text transition-colors"
> >
← Back to site ← Back to site
+9 -11
View File
@@ -32,12 +32,12 @@
function initiateDelete(project: AdminProject) { function initiateDelete(project: AdminProject) {
deleteTarget = project; deleteTarget = project;
deleteConfirmReady = false; deleteConfirmReady = false;
// Enable confirm button after delay // Enable confirm button after delay
deleteTimeout = setTimeout(() => { deleteTimeout = setTimeout(() => {
deleteConfirmReady = true; deleteConfirmReady = true;
}, 2000); }, 2000);
deleteModalOpen = true; deleteModalOpen = true;
} }
@@ -84,9 +84,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-xl font-semibold text-zinc-50">Projects</h1> <h1 class="text-xl font-semibold text-zinc-50">Projects</h1>
<p class="mt-1 text-sm text-zinc-500"> <p class="mt-1 text-sm text-zinc-500">Manage your project portfolio</p>
Manage your project portfolio
</p>
</div> </div>
<Button variant="primary" href="/admin/projects/new"> <Button variant="primary" href="/admin/projects/new">
<IconPlus class="w-4 h-4 mr-2" /> <IconPlus class="w-4 h-4 mr-2" />
@@ -96,13 +94,13 @@
<!-- Projects Table --> <!-- Projects Table -->
{#if loading} {#if loading}
<div class="text-center py-12 text-zinc-500"> <div class="text-center py-12 text-zinc-500">Loading projects...</div>
Loading projects...
</div>
{:else if projects.length === 0} {:else if projects.length === 0}
<div class="text-center py-12"> <div class="text-center py-12">
<p class="text-zinc-500 mb-4">No projects yet</p> <p class="text-zinc-500 mb-4">No projects yet</p>
<Button variant="primary" href="/admin/projects/new">Create your first project</Button> <Button variant="primary" href="/admin/projects/new"
>Create your first project</Button
>
</div> </div>
{:else} {:else}
<Table> <Table>
@@ -129,7 +127,7 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-800/50"> <tbody class="divide-y divide-zinc-800/50">
{#each projects as project} {#each projects as project (project.id)}
<tr class="hover:bg-zinc-800/30 transition-colors"> <tr class="hover:bg-zinc-800/30 transition-colors">
<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">
@@ -150,7 +148,7 @@
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each project.tags.slice(0, 3) as tag} {#each project.tags.slice(0, 3) as tag (tag.id)}
<Badge variant="default">{tag.name}</Badge> <Badge variant="default">{tag.name}</Badge>
{/each} {/each}
{#if project.tags.length > 3} {#if project.tags.length > 3}
+23 -13
View File
@@ -1,9 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import ProjectForm from "$lib/components/admin/ProjectForm.svelte"; import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
import { getAdminProject, getAdminTags, updateAdminProject } from "$lib/api"; import { getAdminProject, getAdminTags, updateAdminProject } from "$lib/api";
import type { AdminProject, AdminTag, AdminTagWithCount, CreateProjectData, UpdateProjectData } from "$lib/admin-types"; import type {
AdminProject,
AdminTag,
AdminTagWithCount,
CreateProjectData,
UpdateProjectData,
} from "$lib/admin-types";
const projectId = $derived(($page.params as { id: string }).id); const projectId = $derived(($page.params as { id: string }).id);
@@ -19,16 +26,18 @@
]); ]);
project = projectData; project = projectData;
tags = tagsWithCounts.map((t: AdminTagWithCount): AdminTag => ({ tags = tagsWithCounts.map(
id: t.id, (t: AdminTagWithCount): AdminTag => ({
slug: t.slug, id: t.id,
name: t.name, slug: t.slug,
createdAt: t.createdAt name: t.name,
})); createdAt: t.createdAt,
}),
);
} catch (error) { } catch (error) {
console.error("Failed to load data:", error); console.error("Failed to load data:", error);
alert("Failed to load project"); alert("Failed to load project");
goto("/admin/projects"); goto(resolve("/admin/projects"));
} finally { } finally {
loading = false; loading = false;
} }
@@ -44,7 +53,7 @@
id: projectId, id: projectId,
}; };
await updateAdminProject(updateData); await updateAdminProject(updateData);
goto("/admin/projects"); goto(resolve("/admin/projects"));
} }
</script> </script>
@@ -63,13 +72,14 @@
<!-- Form --> <!-- Form -->
{#if loading} {#if loading}
<div class="text-center py-12 text-admin-text-muted"> <div class="text-center py-12 text-admin-text-muted">Loading...</div>
Loading...
</div>
{:else if !project} {:else if !project}
<div class="text-center py-12"> <div class="text-center py-12">
<p class="text-admin-text-muted mb-4">Project not found</p> <p class="text-admin-text-muted mb-4">Project not found</p>
<a href="/admin/projects" class="text-blue-400 hover:text-blue-300"> <a
href={resolve("/admin/projects")}
class="text-blue-400 hover:text-blue-300"
>
← Back to projects ← Back to projects
</a> </a>
</div> </div>
+16 -11
View File
@@ -1,8 +1,13 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import ProjectForm from "$lib/components/admin/ProjectForm.svelte"; import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
import { getAdminTags, createAdminProject } from "$lib/api"; import { getAdminTags, createAdminProject } from "$lib/api";
import type { AdminTag, AdminTagWithCount, CreateProjectData } from "$lib/admin-types"; import type {
AdminTag,
AdminTagWithCount,
CreateProjectData,
} from "$lib/admin-types";
let tags = $state<AdminTag[]>([]); let tags = $state<AdminTag[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -10,12 +15,14 @@
async function loadTags() { async function loadTags() {
try { try {
const tagsWithCounts = await getAdminTags(); const tagsWithCounts = await getAdminTags();
tags = tagsWithCounts.map((t: AdminTagWithCount): AdminTag => ({ tags = tagsWithCounts.map(
id: t.id, (t: AdminTagWithCount): AdminTag => ({
slug: t.slug, id: t.id,
name: t.name, slug: t.slug,
createdAt: t.createdAt name: t.name,
})); createdAt: t.createdAt,
}),
);
} catch (error) { } catch (error) {
console.error("Failed to load tags:", error); console.error("Failed to load tags:", error);
} finally { } finally {
@@ -29,7 +36,7 @@
async function handleSubmit(data: CreateProjectData) { async function handleSubmit(data: CreateProjectData) {
await createAdminProject(data); await createAdminProject(data);
goto("/admin/projects"); goto(resolve("/admin/projects"));
} }
</script> </script>
@@ -48,9 +55,7 @@
<!-- Form --> <!-- Form -->
{#if loading} {#if loading}
<div class="text-center py-12 text-admin-text-muted"> <div class="text-center py-12 text-admin-text-muted">Loading...</div>
Loading...
</div>
{:else} {:else}
<div class="rounded-lg border border-admin-border bg-admin-panel p-6"> <div class="rounded-lg border border-admin-border bg-admin-panel p-6">
<ProjectForm <ProjectForm
@@ -17,12 +17,14 @@
let settings = $state<SiteSettings | null>(null); let settings = $state<SiteSettings | null>(null);
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
// Read tab from URL, default to "identity" // Read tab from URL, default to "identity"
let activeTab = $derived.by(() => { let activeTab = $derived.by(() => {
const params = $page.params as { tab?: string }; const params = $page.params as { tab?: string };
const tab = params.tab as Tab | undefined; const tab = params.tab as Tab | undefined;
return tab && ["identity", "social", "admin"].includes(tab) ? tab : "identity"; return tab && ["identity", "social", "admin"].includes(tab)
? tab
: "identity";
}); });
// Form state - will be populated when settings load // Form state - will be populated when settings load
@@ -98,6 +100,7 @@
} }
function navigateToTab(tab: Tab) { function navigateToTab(tab: Tab) {
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`/admin/settings/${tab}`, { replaceState: true }); goto(`/admin/settings/${tab}`, { replaceState: true });
} }
</script> </script>
@@ -127,7 +130,7 @@
"pb-3 px-1 text-sm font-medium border-b-2 transition-colors", "pb-3 px-1 text-sm font-medium border-b-2 transition-colors",
activeTab === "identity" activeTab === "identity"
? "border-indigo-500 text-zinc-50" ? "border-indigo-500 text-zinc-50"
: "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700" : "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700",
)} )}
onclick={() => navigateToTab("identity")} onclick={() => navigateToTab("identity")}
> >
@@ -139,7 +142,7 @@
"pb-3 px-1 text-sm font-medium border-b-2 transition-colors", "pb-3 px-1 text-sm font-medium border-b-2 transition-colors",
activeTab === "social" activeTab === "social"
? "border-indigo-500 text-zinc-50" ? "border-indigo-500 text-zinc-50"
: "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700" : "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700",
)} )}
onclick={() => navigateToTab("social")} onclick={() => navigateToTab("social")}
> >
@@ -151,7 +154,7 @@
"pb-3 px-1 text-sm font-medium border-b-2 transition-colors", "pb-3 px-1 text-sm font-medium border-b-2 transition-colors",
activeTab === "admin" activeTab === "admin"
? "border-indigo-500 text-zinc-50" ? "border-indigo-500 text-zinc-50"
: "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700" : "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700",
)} )}
onclick={() => navigateToTab("admin")} onclick={() => navigateToTab("admin")}
> >
@@ -161,10 +164,14 @@
</div> </div>
<!-- Tab Content --> <!-- Tab Content -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-sm shadow-black/20"> <div
class="rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-sm shadow-black/20"
>
{#if activeTab === "identity"} {#if activeTab === "identity"}
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-base font-medium text-zinc-200 mb-4">Site Identity</h3> <h3 class="text-base font-medium text-zinc-200 mb-4">
Site Identity
</h3>
<Input <Input
label="Display Name" label="Display Name"
type="text" type="text"
@@ -204,7 +211,7 @@
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
{#each formData.socialLinks as link} {#each formData.socialLinks as link (link.id)}
{@const Icon = getSocialIcon(link.platform)} {@const Icon = getSocialIcon(link.platform)}
<div <div
class="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 hover:border-zinc-700 transition-colors" class="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 hover:border-zinc-700 transition-colors"
@@ -215,7 +222,9 @@
</div> </div>
<div class="flex-1 space-y-3"> <div class="flex-1 space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-zinc-200">{link.label}</span> <span class="text-sm font-medium text-zinc-200"
>{link.label}</span
>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<span class="text-xs text-zinc-500">Visible</span> <span class="text-xs text-zinc-500">Visible</span>
<input <input
@@ -238,7 +247,9 @@
</div> </div>
{:else if activeTab === "admin"} {:else if activeTab === "admin"}
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-base font-medium text-zinc-200 mb-4">Admin Preferences</h3> <h3 class="text-base font-medium text-zinc-200 mb-4">
Admin Preferences
</h3>
<Input <Input
label="Session Timeout" label="Session Timeout"
type="number" type="number"
+27 -12
View File
@@ -3,14 +3,23 @@
import Input from "$lib/components/admin/Input.svelte"; import Input from "$lib/components/admin/Input.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 Modal from "$lib/components/admin/Modal.svelte";
import { getAdminTags, createAdminTag, updateAdminTag, deleteAdminTag } from "$lib/api"; import {
import type { AdminTagWithCount, CreateTagData, UpdateTagData } from "$lib/admin-types"; getAdminTags,
createAdminTag,
updateAdminTag,
deleteAdminTag,
} from "$lib/api";
import type {
AdminTagWithCount,
CreateTagData,
UpdateTagData,
} from "$lib/admin-types";
import IconPlus from "~icons/lucide/plus"; import IconPlus from "~icons/lucide/plus";
import IconX from "~icons/lucide/x"; import IconX from "~icons/lucide/x";
let tags = $state<AdminTagWithCount[]>([]); let tags = $state<AdminTagWithCount[]>([]);
let loading = $state(true); let loading = $state(true);
// Create form state // Create form state
let showCreateForm = $state(false); let showCreateForm = $state(false);
let createName = $state(""); let createName = $state("");
@@ -101,12 +110,12 @@
function initiateDelete(tag: AdminTagWithCount) { function initiateDelete(tag: AdminTagWithCount) {
deleteTarget = tag; deleteTarget = tag;
deleteConfirmReady = false; deleteConfirmReady = false;
// Enable confirm button after delay // Enable confirm button after delay
deleteTimeout = setTimeout(() => { deleteTimeout = setTimeout(() => {
deleteConfirmReady = true; deleteConfirmReady = true;
}, 2000); }, 2000);
deleteModalOpen = true; deleteModalOpen = true;
} }
@@ -148,7 +157,10 @@
Manage project tags and categories Manage project tags and categories
</p> </p>
</div> </div>
<Button variant="primary" onclick={() => (showCreateForm = !showCreateForm)}> <Button
variant="primary"
onclick={() => (showCreateForm = !showCreateForm)}
>
{#if showCreateForm} {#if showCreateForm}
<IconX class="w-4 h-4 mr-2" /> <IconX class="w-4 h-4 mr-2" />
{:else} {:else}
@@ -160,7 +172,9 @@
<!-- Create Form --> <!-- Create Form -->
{#if showCreateForm} {#if showCreateForm}
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-sm shadow-black/20"> <div
class="rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-sm shadow-black/20"
>
<h3 class="text-base font-medium text-zinc-200 mb-4">Create New Tag</h3> <h3 class="text-base font-medium text-zinc-200 mb-4">Create New Tag</h3>
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<Input <Input
@@ -194,9 +208,7 @@
<!-- Tags Table --> <!-- Tags Table -->
{#if loading} {#if loading}
<div class="text-center py-12 text-zinc-500"> <div class="text-center py-12 text-zinc-500">Loading tags...</div>
Loading tags...
</div>
{:else if tags.length === 0} {:else if tags.length === 0}
<div class="text-center py-12"> <div class="text-center py-12">
<p class="text-zinc-500 mb-4">No tags yet</p> <p class="text-zinc-500 mb-4">No tags yet</p>
@@ -223,7 +235,7 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-800/50"> <tbody class="divide-y divide-zinc-800/50">
{#each tags as tag} {#each tags as tag (tag.id)}
<tr class="hover:bg-zinc-800/30 transition-colors"> <tr class="hover:bg-zinc-800/30 transition-colors">
{#if editingId === tag.id} {#if editingId === tag.id}
<!-- Edit mode --> <!-- Edit mode -->
@@ -315,7 +327,10 @@
<div class="rounded-md bg-zinc-800/50 border border-zinc-700 p-3"> <div class="rounded-md bg-zinc-800/50 border border-zinc-700 p-3">
<p class="font-medium text-zinc-200">{deleteTarget.name}</p> <p class="font-medium text-zinc-200">{deleteTarget.name}</p>
<p class="text-sm text-zinc-500"> <p class="text-sm text-zinc-500">
Used in {deleteTarget.projectCount} project{deleteTarget.projectCount === 1 ? "" : "s"} Used in {deleteTarget.projectCount} project{deleteTarget.projectCount ===
1
? ""
: "s"}
</p> </p>
</div> </div>
{/if} {/if}
+6 -6
View File
@@ -6,7 +6,7 @@ import type { EntryGenerator, PageServerLoad } from "./$types";
* This generates static HTML files at build time. * This generates static HTML files at build time.
*/ */
export const entries: EntryGenerator = () => { export const entries: EntryGenerator = () => {
return Object.keys(ERROR_CODES).map((code) => ({ code })); return Object.keys(ERROR_CODES).map((code) => ({ code }));
}; };
export const prerender = true; export const prerender = true;
@@ -16,10 +16,10 @@ export const prerender = true;
* This runs during prerendering to generate static HTML. * This runs during prerendering to generate static HTML.
*/ */
export const load: PageServerLoad = ({ params }) => { export const load: PageServerLoad = ({ params }) => {
const code = parseInt(params.code, 10) as keyof typeof ERROR_CODES; const code = parseInt(params.code, 10) as keyof typeof ERROR_CODES;
return { return {
code, code,
...ERROR_CODES[code], ...ERROR_CODES[code],
}; };
}; };
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import AppWrapper from "$components/AppWrapper.svelte"; import AppWrapper from "$components/AppWrapper.svelte";
import Dots from "$lib/components/Dots.svelte";
let { data } = $props(); let { data } = $props();
+2 -1
View File
@@ -2,6 +2,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import { resolve } from "$app/paths";
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -62,7 +63,7 @@
<div class="examples"> <div class="examples">
<h2>Example URLs:</h2> <h2>Example URLs:</h2>
<ul> <ul>
<li><a href="/internal/ogp?type=index">Index page</a></li> <li><a href={resolve("/internal/ogp")}>Index page</a></li>
<li><a href="/internal/ogp?type=projects">Projects page</a></li> <li><a href="/internal/ogp?type=projects">Projects page</a></li>
<li> <li>
<a href="/internal/ogp?type=project&id=example-id" <a href="/internal/ogp?type=project&id=example-id"
+1 -1
View File
@@ -25,6 +25,7 @@ function railwayFormatter(record: LogRecord): string {
} }
function stripAnsi(str: string): string { function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\u001b\[[0-9;]*m/g, "").trim(); return str.replace(/\u001b\[[0-9;]*m/g, "").trim();
} }
@@ -115,7 +116,6 @@ export function jsonLogger(): Plugin {
server = s; server = s;
const logger = getLogger(["vite"]); const logger = getLogger(["vite"]);
const originalPrintUrls = server.printUrls;
server.printUrls = () => { server.printUrls = () => {
const urls = server.resolvedUrls; const urls = server.resolvedUrls;
if (urls) { if (urls) {