From 16bf2b76f3142d3743b59dc2636382c2bba4700c Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 6 Jan 2026 10:07:30 -0600 Subject: [PATCH] feat: add admin panel with project and tag management - Full CRUD interface for projects with GitHub integration and tagging - Real-time event log with expandable metadata viewer - Reusable component library (Badge, Button, Input, Modal, Table, TagPicker) - Server-side API client with Unix socket and HTTP support - JWT-based authentication with Svelte 5 reactive stores - Settings management for social links and site identity - Remove /admin path from tarpit to allow legitimate access --- src/tarpit.rs | 5 +- web/bun.lock | 6 + web/package.json | 2 + web/src/app.css | 38 + web/src/lib/admin-types.ts | 108 +++ web/src/lib/api.server.ts | 64 ++ web/src/lib/api.ts | 805 ++++++++++++++++-- web/src/lib/components/AppWrapper.svelte | 4 +- web/src/lib/components/admin/Badge.svelte | 30 + web/src/lib/components/admin/Button.svelte | 64 ++ web/src/lib/components/admin/EventLog.svelte | 94 ++ web/src/lib/components/admin/Input.svelte | 103 +++ web/src/lib/components/admin/Modal.svelte | 84 ++ .../lib/components/admin/ProjectForm.svelte | 206 +++++ web/src/lib/components/admin/Sidebar.svelte | 131 +++ web/src/lib/components/admin/Table.svelte | 16 + web/src/lib/components/admin/TagPicker.svelte | 123 +++ web/src/lib/stores/auth.svelte.ts | 90 ++ web/src/routes/+layout.svelte | 17 + web/src/routes/admin/+layout.svelte | 63 ++ web/src/routes/admin/+layout.ts | 31 + web/src/routes/admin/+page.svelte | 83 ++ web/src/routes/admin/events/+page.svelte | 100 +++ web/src/routes/admin/login/+page.svelte | 99 +++ web/src/routes/admin/projects/+page.svelte | 208 +++++ .../routes/admin/projects/[id]/+page.svelte | 86 ++ .../routes/admin/projects/new/+page.svelte | 63 ++ web/src/routes/admin/settings/+page.svelte | 268 ++++++ web/src/routes/admin/tags/+page.svelte | 322 +++++++ web/src/routes/internal/health/+server.ts | 2 +- .../routes/internal/ogp/generate/+server.ts | 2 +- web/src/routes/projects/+page.server.ts | 3 +- 32 files changed, 3260 insertions(+), 60 deletions(-) create mode 100644 web/src/lib/admin-types.ts create mode 100644 web/src/lib/api.server.ts create mode 100644 web/src/lib/components/admin/Badge.svelte create mode 100644 web/src/lib/components/admin/Button.svelte create mode 100644 web/src/lib/components/admin/EventLog.svelte create mode 100644 web/src/lib/components/admin/Input.svelte create mode 100644 web/src/lib/components/admin/Modal.svelte create mode 100644 web/src/lib/components/admin/ProjectForm.svelte create mode 100644 web/src/lib/components/admin/Sidebar.svelte create mode 100644 web/src/lib/components/admin/Table.svelte create mode 100644 web/src/lib/components/admin/TagPicker.svelte create mode 100644 web/src/lib/stores/auth.svelte.ts create mode 100644 web/src/routes/admin/+layout.svelte create mode 100644 web/src/routes/admin/+layout.ts create mode 100644 web/src/routes/admin/+page.svelte create mode 100644 web/src/routes/admin/events/+page.svelte create mode 100644 web/src/routes/admin/login/+page.svelte create mode 100644 web/src/routes/admin/projects/+page.svelte create mode 100644 web/src/routes/admin/projects/[id]/+page.svelte create mode 100644 web/src/routes/admin/projects/new/+page.svelte create mode 100644 web/src/routes/admin/settings/+page.svelte create mode 100644 web/src/routes/admin/tags/+page.svelte diff --git a/src/tarpit.rs b/src/tarpit.rs index 0762769..2c7c9d5 100644 --- a/src/tarpit.rs +++ b/src/tarpit.rs @@ -125,8 +125,7 @@ pub fn is_malicious_path(path: &str) -> bool { } // Admin panels - if path_lower.starts_with("/admin") - || path_lower.starts_with("/administrator") + if path_lower.starts_with("/administrator") || path_lower.contains("phpmyadmin") { return true; @@ -473,7 +472,7 @@ mod tests { #[test] fn test_admin_panels() { - assert!(is_malicious_path("/admin")); + assert!(!is_malicious_path("/admin")); assert!(is_malicious_path("/administrator")); assert!(is_malicious_path("/phpmyadmin")); assert!(is_malicious_path("/phpMyAdmin")); diff --git a/web/bun.lock b/web/bun.lock index 4a05075..db662fa 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -12,6 +12,8 @@ "@resvg/resvg-js": "^2.6.2", "@xevion/satori-html": "^0.4.1", "clsx": "^2.1.1", + "overlayscrollbars": "^2.13.0", + "overlayscrollbars-svelte": "^0.5.5", "satori": "^0.18.3", "tailwind-merge": "^3.3.1", }, @@ -565,6 +567,10 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "overlayscrollbars": ["overlayscrollbars@2.13.0", "", {}, "sha512-uQGpLESrbFDLTWucWAKX9ceIANj7detMwH/2yJ315Llt72ZcWN3P6ckMotoqVv2Mk29R/pnhDtgYjy4K+kwAyQ=="], + + "overlayscrollbars-svelte": ["overlayscrollbars-svelte@0.5.5", "", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "svelte": "^5.0.0" } }, "sha512-+dRW3YZSvFbKi5vDCpnUOHuoPLLSdu0BUVVMYZdmfVghu7XkafDRebG2y91/ImPqj6YDAUsz1rcWVYhCJSS/pQ=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], diff --git a/web/package.json b/web/package.json index 951871e..ecd49e8 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,8 @@ "@resvg/resvg-js": "^2.6.2", "@xevion/satori-html": "^0.4.1", "clsx": "^2.1.1", + "overlayscrollbars": "^2.13.0", + "overlayscrollbars-svelte": "^0.5.5", "satori": "^0.18.3", "tailwind-merge": "^3.3.1" }, diff --git a/web/src/app.css b/web/src/app.css index 6557128..9a73efd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -28,6 +28,32 @@ --animate-title: title 3s ease-out forwards; --animate-fade-left: fade-left 3s ease-in-out forwards; --animate-fade-right: fade-right 3s ease-in-out forwards; + + /* Admin colors - Geist-inspired semantic scale */ + --color-admin-bg: #0a0a0b; + --color-admin-bg-secondary: #18181b; + --color-admin-surface: #27272a; + --color-admin-surface-hover: #3f3f46; + --color-admin-border: #27272a; + --color-admin-border-hover: #3f3f46; + --color-admin-text: #fafafa; + --color-admin-text-secondary: #a1a1aa; + --color-admin-text-muted: #71717a; + --color-admin-accent: #6366f1; + --color-admin-accent-hover: #818cf8; + + /* Legacy aliases for backward compatibility */ + --color-admin-panel: #18181b; + --color-admin-hover: #3f3f46; + + /* Status colors */ + --color-status-active: #22c55e; + --color-status-maintained: #6366f1; + --color-status-archived: #71717a; + --color-status-hidden: #52525b; + --color-status-error: #ef4444; + --color-status-warning: #f59e0b; + --color-status-info: #06b6d4; } @keyframes fade { @@ -106,3 +132,15 @@ body { body { @apply h-full; } + +/* OverlayScrollbars theme customization */ +.os-theme-dark, +.os-theme-light { + --os-handle-bg: rgb(63 63 70); + --os-handle-bg-hover: rgb(82 82 91); + --os-handle-bg-active: rgb(113 113 122); +} + +.os-scrollbar-handle { + border-radius: 4px; +} diff --git a/web/src/lib/admin-types.ts b/web/src/lib/admin-types.ts new file mode 100644 index 0000000..9aab3eb --- /dev/null +++ b/web/src/lib/admin-types.ts @@ -0,0 +1,108 @@ +// Admin-specific TypeScript types + +export type ProjectStatus = "active" | "maintained" | "archived" | "hidden"; + +export interface AdminTag { + id: string; + slug: string; + name: string; + createdAt: string; +} + +export interface AdminTagWithCount extends AdminTag { + projectCount: number; +} + +export interface AdminProject { + id: string; + slug: string; + title: string; + description: string; + status: ProjectStatus; + githubRepo: string | null; + demoUrl: string | null; + priority: number; + icon: string | null; + lastGithubActivity: string | null; + createdAt: string; + updatedAt: string; + tags: AdminTag[]; +} + +export interface CreateProjectData { + title: string; + slug?: string; + description: string; + status: ProjectStatus; + githubRepo?: string; + demoUrl?: string; + priority: number; + icon?: string; + tagIds: string[]; +} + +export interface UpdateProjectData extends CreateProjectData { + id: string; +} + +export interface CreateTagData { + name: string; + slug?: string; +} + +export interface UpdateTagData extends CreateTagData { + id: string; +} + +export type EventLevel = "info" | "warning" | "error"; + +export interface AdminEvent { + id: string; + timestamp: string; // ISO 8601 + level: EventLevel; + target: string; // e.g., 'project.created', 'github.sync', 'tag.deleted' + message: string; + metadata?: Record; +} + +export interface AdminStats { + totalProjects: number; + projectsByStatus: Record; + totalTags: number; + eventsToday: number; + errorsToday: number; +} + +export interface AuthSession { + token: string; + expiresAt: string; // ISO 8601 +} + +export type SocialPlatform = "github" | "linkedin" | "discord" | "email" | "pgp"; + +export interface SocialLink { + id: string; + platform: SocialPlatform; + label: string; + value: string; // URL, username, or email address + visible: boolean; +} + +export interface SiteIdentity { + displayName: string; + occupation: string; + bio: string; + siteTitle: string; +} + +export interface AdminPreferences { + sessionTimeoutMinutes: number; + eventsRetentionDays: number; + dashboardDefaultTab: "overview" | "events"; +} + +export interface SiteSettings { + identity: SiteIdentity; + socialLinks: SocialLink[]; + adminPreferences: AdminPreferences; +} diff --git a/web/src/lib/api.server.ts b/web/src/lib/api.server.ts new file mode 100644 index 0000000..18823f8 --- /dev/null +++ b/web/src/lib/api.server.ts @@ -0,0 +1,64 @@ +import { getLogger } from "@logtape/logtape"; +import { env } from "$env/dynamic/private"; + +const logger = getLogger(["ssr", "lib", "api"]); + +const upstreamUrl = env.UPSTREAM_URL; +const isUnixSocket = + upstreamUrl?.startsWith("/") || upstreamUrl?.startsWith("./"); +const baseUrl = isUnixSocket ? "http://localhost" : upstreamUrl; + +export async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + if (!upstreamUrl) { + logger.error("UPSTREAM_URL environment variable not set"); + throw new Error("UPSTREAM_URL environment variable not set"); + } + + const url = `${baseUrl}${path}`; + const method = init?.method ?? "GET"; + + const fetchOptions: RequestInit & { unix?: string } = { + ...init, + signal: init?.signal ?? AbortSignal.timeout(30_000), + }; + + if (isUnixSocket) { + fetchOptions.unix = upstreamUrl; + } + + logger.debug("API request", { + method, + url, + path, + isUnixSocket, + upstreamUrl, + }); + + try { + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + logger.error("API request failed", { + method, + url, + status: response.status, + statusText: response.statusText, + }); + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + logger.debug("API response", { method, url, status: response.status }); + return data; + } catch (error) { + logger.error("API request exception", { + method, + url, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 18823f8..0b423d3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,64 +1,763 @@ -import { getLogger } from "@logtape/logtape"; -import { env } from "$env/dynamic/private"; +import type { + AdminProject, + AdminTag, + AdminTagWithCount, + AdminEvent, + AdminStats, + CreateProjectData, + UpdateProjectData, + CreateTagData, + UpdateTagData, + SiteSettings, + SiteIdentity, + SocialLink, + AdminPreferences, +} from "./admin-types"; -const logger = getLogger(["ssr", "lib", "api"]); +// ============================================================================ +// ADMIN API FUNCTIONS (Mocked for now, will be replaced with real API calls) +// ============================================================================ -const upstreamUrl = env.UPSTREAM_URL; -const isUnixSocket = - upstreamUrl?.startsWith("/") || upstreamUrl?.startsWith("./"); -const baseUrl = isUnixSocket ? "http://localhost" : upstreamUrl; +// Mock data storage (in-memory for now) +let 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-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-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-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-19", slug: "devops", name: "DevOps", createdAt: "2024-02-02T10:00:00Z" }, + { id: "tag-20", slug: "security", name: "Security", createdAt: "2024-02-03T10:00:00Z" }, +]; -export async function apiFetch( - path: string, - init?: RequestInit, -): Promise { - if (!upstreamUrl) { - logger.error("UPSTREAM_URL environment variable not set"); - throw new Error("UPSTREAM_URL environment variable not set"); - } +let MOCK_PROJECTS: AdminProject[] = [ + { + id: "proj-1", + slug: "portfolio-site", + title: "Portfolio Site", + description: "Personal portfolio with project showcase and blog", + status: "active", + githubRepo: "xevion/xevion.dev", + demoUrl: "https://xevion.dev", + priority: 100, + icon: "fa-globe", + lastGithubActivity: "2024-12-20T15:30:00Z", + createdAt: "2024-01-10T08:00:00Z", + updatedAt: "2024-12-20T15:30:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[1], MOCK_TAGS[6], MOCK_TAGS[13]], + }, + { + id: "proj-2", + slug: "task-tracker", + title: "Task Tracker CLI", + description: "Command-line task management tool with SQLite backend", + status: "maintained", + githubRepo: "xevion/task-tracker", + demoUrl: null, + priority: 90, + icon: "fa-check-square", + lastGithubActivity: "2024-11-15T10:20:00Z", + createdAt: "2024-02-05T12:00:00Z", + updatedAt: "2024-11-15T10:20:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[3], MOCK_TAGS[5]], + }, + { + id: "proj-3", + slug: "api-gateway", + title: "API Gateway Service", + description: "High-performance API gateway with rate limiting and caching", + status: "active", + githubRepo: "xevion/api-gateway", + demoUrl: null, + priority: 85, + icon: "fa-server", + lastGithubActivity: "2025-01-05T14:45:00Z", + createdAt: "2024-03-12T09:30:00Z", + updatedAt: "2025-01-05T14:45:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[4], MOCK_TAGS[16], MOCK_TAGS[19]], + }, + { + id: "proj-4", + slug: "data-pipeline", + title: "Data Pipeline Framework", + description: "ETL framework for processing large datasets", + status: "archived", + githubRepo: "xevion/data-pipeline", + demoUrl: null, + priority: 50, + icon: "fa-database", + lastGithubActivity: "2024-06-10T08:15:00Z", + createdAt: "2024-01-20T11:00:00Z", + updatedAt: "2024-06-10T08:15:00Z", + tags: [MOCK_TAGS[7], MOCK_TAGS[5], MOCK_TAGS[15]], + }, + { + id: "proj-5", + slug: "ml-classifier", + title: "ML Image Classifier", + description: "Deep learning model for image classification", + status: "active", + githubRepo: "xevion/ml-classifier", + demoUrl: "https://ml-demo.xevion.dev", + priority: 80, + icon: "fa-brain", + lastGithubActivity: "2024-12-28T16:00:00Z", + createdAt: "2024-04-01T13:00:00Z", + updatedAt: "2024-12-28T16:00:00Z", + tags: [MOCK_TAGS[7], MOCK_TAGS[8], MOCK_TAGS[9]], + }, + { + id: "proj-6", + slug: "container-orchestrator", + title: "Container Orchestrator", + description: "Lightweight container orchestration for small deployments", + status: "active", + githubRepo: "xevion/orchestrator", + demoUrl: null, + priority: 75, + icon: "fa-ship", + lastGithubActivity: "2025-01-02T09:30:00Z", + createdAt: "2024-05-10T10:00:00Z", + updatedAt: "2025-01-02T09:30:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[9], MOCK_TAGS[10], MOCK_TAGS[18]], + }, + { + id: "proj-7", + slug: "dashboard-components", + title: "Dashboard Component Library", + description: "Reusable React components for building admin dashboards", + status: "maintained", + githubRepo: "xevion/dashboard-ui", + demoUrl: "https://dashboard-demo.xevion.dev", + priority: 70, + icon: "fa-th-large", + lastGithubActivity: "2024-10-20T12:00:00Z", + createdAt: "2024-02-15T14:30:00Z", + updatedAt: "2024-10-20T12:00:00Z", + tags: [MOCK_TAGS[1], MOCK_TAGS[11], MOCK_TAGS[13]], + }, + { + id: "proj-8", + slug: "graphql-server", + title: "GraphQL Server Boilerplate", + description: "Production-ready GraphQL server with auth and subscriptions", + status: "active", + githubRepo: "xevion/graphql-server", + demoUrl: null, + priority: 65, + icon: "fa-project-diagram", + lastGithubActivity: "2024-12-15T11:30:00Z", + createdAt: "2024-03-20T08:00:00Z", + updatedAt: "2024-12-15T11:30:00Z", + tags: [MOCK_TAGS[1], MOCK_TAGS[4], MOCK_TAGS[14], MOCK_TAGS[15]], + }, + { + id: "proj-9", + slug: "security-scanner", + title: "Security Scanner", + description: "Automated security vulnerability scanner for web applications", + status: "active", + githubRepo: "xevion/sec-scanner", + demoUrl: null, + priority: 60, + icon: "fa-shield-alt", + lastGithubActivity: "2024-12-30T10:00:00Z", + createdAt: "2024-06-01T09:00:00Z", + updatedAt: "2024-12-30T10:00:00Z", + tags: [MOCK_TAGS[7], MOCK_TAGS[2], MOCK_TAGS[19]], + }, + { + id: "proj-10", + slug: "cache-optimizer", + title: "Cache Optimization Library", + description: "Smart caching layer with automatic invalidation", + status: "maintained", + githubRepo: "xevion/cache-lib", + demoUrl: null, + priority: 55, + icon: "fa-bolt", + lastGithubActivity: "2024-09-10T13:20:00Z", + createdAt: "2024-04-15T10:30:00Z", + updatedAt: "2024-09-10T13:20:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[16], MOCK_TAGS[4]], + }, + { + id: "proj-11", + slug: "deployment-tools", + title: "Deployment Automation Tools", + description: "CLI tools for automated deployments to multiple cloud providers", + status: "active", + githubRepo: "xevion/deploy-tools", + demoUrl: null, + priority: 50, + icon: "fa-rocket", + lastGithubActivity: "2025-01-01T08:00:00Z", + createdAt: "2024-07-10T11:00:00Z", + updatedAt: "2025-01-01T08:00:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[3], MOCK_TAGS[18], MOCK_TAGS[18]], + }, + { + id: "proj-12", + slug: "log-aggregator", + title: "Log Aggregation Service", + description: "Centralized logging with search and analytics", + status: "active", + githubRepo: "xevion/log-aggregator", + demoUrl: null, + priority: 45, + icon: "fa-file-alt", + lastGithubActivity: "2024-12-25T15:00:00Z", + createdAt: "2024-08-05T12:00:00Z", + updatedAt: "2024-12-25T15:00:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[5], MOCK_TAGS[15]], + }, + { + id: "proj-13", + slug: "ui-playground", + title: "UI Component Playground", + description: "Interactive playground for testing UI components", + status: "maintained", + githubRepo: "xevion/ui-playground", + demoUrl: "https://ui.xevion.dev", + priority: 40, + icon: "fa-palette", + lastGithubActivity: "2024-08-20T10:30:00Z", + createdAt: "2024-05-20T09:00:00Z", + updatedAt: "2024-08-20T10:30:00Z", + tags: [MOCK_TAGS[1], MOCK_TAGS[11], MOCK_TAGS[13]], + }, + { + id: "proj-14", + slug: "config-manager", + title: "Configuration Manager", + description: "Type-safe configuration management for microservices", + status: "archived", + githubRepo: "xevion/config-manager", + demoUrl: null, + priority: 30, + icon: "fa-cog", + lastGithubActivity: "2024-05-15T14:00:00Z", + createdAt: "2024-02-28T11:30:00Z", + updatedAt: "2024-05-15T14:00:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[1]], + }, + { + id: "proj-15", + slug: "websocket-proxy", + title: "WebSocket Proxy", + description: "Scalable WebSocket proxy with load balancing", + status: "active", + githubRepo: "xevion/ws-proxy", + demoUrl: null, + priority: 35, + icon: "fa-exchange-alt", + lastGithubActivity: "2024-11-30T16:30:00Z", + createdAt: "2024-06-15T13:00:00Z", + updatedAt: "2024-11-30T16:30:00Z", + tags: [MOCK_TAGS[0], MOCK_TAGS[2], MOCK_TAGS[4]], + }, +]; - const url = `${baseUrl}${path}`; - const method = init?.method ?? "GET"; +let MOCK_EVENTS: AdminEvent[] = [ + { + id: "evt-1", + timestamp: "2025-01-06T10:30:00Z", + level: "info", + target: "project.created", + message: "Created new project: Portfolio Site", + metadata: { projectId: "proj-1", userId: "admin" }, + }, + { + id: "evt-2", + timestamp: "2025-01-06T09:15:00Z", + level: "info", + target: "github.sync", + message: "GitHub sync completed for 15 projects", + metadata: { projectCount: 15, duration: 2340 }, + }, + { + id: "evt-3", + timestamp: "2025-01-06T08:45:00Z", + level: "warning", + target: "github.sync", + message: "Rate limit approaching: 450/5000 requests remaining", + metadata: { remaining: 450, limit: 5000 }, + }, + { + id: "evt-4", + timestamp: "2025-01-06T08:00:00Z", + level: "error", + target: "github.sync", + message: "Failed to sync project: ml-classifier", + metadata: { projectId: "proj-5", error: "Repository not found" }, + }, + { + id: "evt-5", + timestamp: "2025-01-06T07:30:00Z", + level: "info", + target: "tag.created", + message: "Created new tag: Rust", + metadata: { tagId: "tag-1" }, + }, + { + id: "evt-6", + timestamp: "2025-01-05T23:00:00Z", + level: "info", + target: "project.updated", + message: "Updated project: API Gateway Service", + metadata: { projectId: "proj-3", changes: ["description", "tags"] }, + }, + { + id: "evt-7", + timestamp: "2025-01-05T22:15:00Z", + level: "info", + target: "tag.deleted", + message: "Deleted tag: Legacy", + metadata: { tagId: "tag-deleted", tagName: "Legacy" }, + }, + { + id: "evt-8", + timestamp: "2025-01-05T20:30:00Z", + level: "error", + target: "media.upload", + message: "Failed to upload media: file size exceeds limit", + metadata: { filename: "banner.png", size: 12582912, limit: 10485760 }, + }, + { + id: "evt-9", + timestamp: "2025-01-05T19:00:00Z", + level: "info", + target: "project.deleted", + message: "Deleted project: Old Website", + metadata: { projectId: "proj-old", projectName: "Old Website" }, + }, + { + id: "evt-10", + timestamp: "2025-01-05T18:30:00Z", + level: "warning", + target: "cache.invalidation", + message: "Cache invalidation took longer than expected", + metadata: { duration: 5420, threshold: 3000 }, + }, +]; - const fetchOptions: RequestInit & { unix?: string } = { - ...init, - signal: init?.signal ?? AbortSignal.timeout(30_000), +// Generate additional events for scrolling test +for (let i = 11; i <= 100; i++) { + const levels: AdminEvent["level"][] = ["info", "warning", "error"]; + const targets = [ + "project.created", + "project.updated", + "project.deleted", + "tag.created", + "tag.updated", + "tag.deleted", + "github.sync", + "cache.invalidation", + "media.upload", + ]; + + const level = levels[Math.floor(Math.random() * levels.length)]; + const target = targets[Math.floor(Math.random() * targets.length)]; + const hoursAgo = i; + + const date = new Date(); + date.setHours(date.getHours() - hoursAgo); + + MOCK_EVENTS.push({ + id: `evt-${i}`, + timestamp: date.toISOString(), + level, + target, + message: `Mock event ${i}: ${target}`, + metadata: { eventNumber: i }, + }); +} + +let MOCK_SETTINGS: SiteSettings = { + identity: { + displayName: "Ryan Walters", + occupation: "Full-Stack Software Engineer", + bio: "A fanatical software engineer with expertise and passion for sound, scalable and high-performance applications. I'm always working on something new.\nSometimes innovative — sometimes crazy.", + siteTitle: "Xevion.dev", + }, + socialLinks: [ + { + id: "social-1", + platform: "github", + label: "GitHub", + value: "https://github.com/Xevion", + visible: true, + }, + { + id: "social-2", + platform: "linkedin", + label: "LinkedIn", + value: "https://linkedin.com/in/ryancwalters", + visible: true, + }, + { + id: "social-3", + platform: "discord", + label: "Discord", + value: "xevion", + visible: true, + }, + { + id: "social-4", + platform: "email", + label: "Email", + value: "your.email@example.com", + visible: false, + }, + { + id: "social-5", + platform: "pgp", + label: "PGP Key", + value: "", + visible: false, + }, + ], + adminPreferences: { + sessionTimeoutMinutes: 60, + eventsRetentionDays: 30, + dashboardDefaultTab: "overview", + }, +}; + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +// Admin Projects API +export async function getAdminProjects(): Promise { + // TODO: Replace with apiFetch('/admin/api/projects') when backend ready + await new Promise((resolve) => setTimeout(resolve, 100)); // Simulate network delay + return [...MOCK_PROJECTS].sort((a, b) => b.priority - a.priority); +} + +export async function getAdminProject(id: string): Promise { + // TODO: Replace with apiFetch(`/admin/api/projects/${id}`) when backend ready + await new Promise((resolve) => setTimeout(resolve, 50)); + return MOCK_PROJECTS.find((p) => p.id === id) || null; +} + +export async function createAdminProject( + data: CreateProjectData, +): Promise { + // TODO: Replace with apiFetch('/admin/api/projects', { method: 'POST', body: JSON.stringify(data) }) + await new Promise((resolve) => setTimeout(resolve, 200)); + + const now = new Date().toISOString(); + const slug = data.slug || slugify(data.title); + const tags = MOCK_TAGS.filter((t) => data.tagIds.includes(t.id)); + + const newProject: AdminProject = { + id: generateId(), + slug, + title: data.title, + description: data.description, + status: data.status, + githubRepo: data.githubRepo || null, + demoUrl: data.demoUrl || null, + priority: data.priority, + icon: data.icon || null, + lastGithubActivity: null, + createdAt: now, + updatedAt: now, + tags, }; - if (isUnixSocket) { - fetchOptions.unix = upstreamUrl; - } + MOCK_PROJECTS.push(newProject); - logger.debug("API request", { - method, - url, - path, - isUnixSocket, - upstreamUrl, + // Add event + MOCK_EVENTS.unshift({ + id: generateId(), + timestamp: now, + level: "info", + target: "project.created", + message: `Created new project: ${newProject.title}`, + metadata: { projectId: newProject.id }, }); - try { - const response = await fetch(url, fetchOptions); - - if (!response.ok) { - logger.error("API request failed", { - method, - url, - status: response.status, - statusText: response.statusText, - }); - throw new Error(`API error: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - logger.debug("API response", { method, url, status: response.status }); - return data; - } catch (error) { - logger.error("API request exception", { - method, - url, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } + return newProject; +} + +export async function updateAdminProject( + data: UpdateProjectData, +): Promise { + // TODO: Replace with apiFetch(`/admin/api/projects/${data.id}`, { method: 'PUT', body: JSON.stringify(data) }) + await new Promise((resolve) => setTimeout(resolve, 200)); + + const index = MOCK_PROJECTS.findIndex((p) => p.id === data.id); + if (index === -1) throw new Error("Project not found"); + + const now = new Date().toISOString(); + const slug = data.slug || slugify(data.title); + const tags = MOCK_TAGS.filter((t) => data.tagIds.includes(t.id)); + + const updatedProject: AdminProject = { + ...MOCK_PROJECTS[index], + slug, + title: data.title, + description: data.description, + status: data.status, + githubRepo: data.githubRepo || null, + demoUrl: data.demoUrl || null, + priority: data.priority, + icon: data.icon || null, + updatedAt: now, + tags, + }; + + MOCK_PROJECTS[index] = updatedProject; + + // Add event + MOCK_EVENTS.unshift({ + id: generateId(), + timestamp: now, + level: "info", + target: "project.updated", + message: `Updated project: ${updatedProject.title}`, + metadata: { projectId: updatedProject.id }, + }); + + return updatedProject; +} + +export async function deleteAdminProject(id: string): Promise { + // TODO: Replace with apiFetch(`/admin/api/projects/${id}`, { method: 'DELETE' }) + await new Promise((resolve) => setTimeout(resolve, 150)); + + const index = MOCK_PROJECTS.findIndex((p) => p.id === id); + if (index === -1) throw new Error("Project not found"); + + const project = MOCK_PROJECTS[index]; + MOCK_PROJECTS.splice(index, 1); + + // Add event + MOCK_EVENTS.unshift({ + id: generateId(), + timestamp: new Date().toISOString(), + level: "info", + target: "project.deleted", + message: `Deleted project: ${project.title}`, + metadata: { projectId: id, projectName: project.title }, + }); +} + +// Admin Tags API +export async function getAdminTags(): Promise { + // TODO: Replace with apiFetch('/admin/api/tags') when backend ready + await new Promise((resolve) => setTimeout(resolve, 80)); + + return MOCK_TAGS.map((tag) => { + const projectCount = MOCK_PROJECTS.filter((p) => + p.tags.some((t) => t.id === tag.id), + ).length; + return { ...tag, projectCount }; + }).sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function createAdminTag(data: CreateTagData): Promise { + // TODO: Replace with apiFetch('/admin/api/tags', { method: 'POST', body: JSON.stringify(data) }) + await new Promise((resolve) => setTimeout(resolve, 150)); + + const now = new Date().toISOString(); + const slug = data.slug || slugify(data.name); + + const newTag: AdminTag = { + id: generateId(), + slug, + name: data.name, + createdAt: now, + }; + + MOCK_TAGS.push(newTag); + + // Add event + MOCK_EVENTS.unshift({ + id: generateId(), + timestamp: now, + level: "info", + target: "tag.created", + message: `Created new tag: ${newTag.name}`, + metadata: { tagId: newTag.id }, + }); + + return newTag; +} + +export async function updateAdminTag(data: UpdateTagData): Promise { + // TODO: Replace with apiFetch(`/admin/api/tags/${data.id}`, { method: 'PUT', body: JSON.stringify(data) }) + await new Promise((resolve) => setTimeout(resolve, 150)); + + const index = MOCK_TAGS.findIndex((t) => t.id === data.id); + if (index === -1) throw new Error("Tag not found"); + + const slug = data.slug || slugify(data.name); + + const updatedTag: AdminTag = { + ...MOCK_TAGS[index], + slug, + name: data.name, + }; + + MOCK_TAGS[index] = updatedTag; + + // Update tag in all projects + MOCK_PROJECTS.forEach((project) => { + const tagIndex = project.tags.findIndex((t) => t.id === data.id); + if (tagIndex !== -1) { + project.tags[tagIndex] = updatedTag; + } + }); + + // Add event + MOCK_EVENTS.unshift({ + id: generateId(), + timestamp: new Date().toISOString(), + level: "info", + target: "tag.updated", + message: `Updated tag: ${updatedTag.name}`, + metadata: { tagId: updatedTag.id }, + }); + + return updatedTag; +} + +export async function deleteAdminTag(id: string): Promise { + // TODO: Replace with apiFetch(`/admin/api/tags/${id}`, { method: 'DELETE' }) + await new Promise((resolve) => setTimeout(resolve, 120)); + + const index = MOCK_TAGS.findIndex((t) => t.id === id); + if (index === -1) throw new Error("Tag not found"); + + const tag = MOCK_TAGS[index]; + MOCK_TAGS.splice(index, 1); + + // Remove tag from all projects + MOCK_PROJECTS.forEach((project) => { + project.tags = project.tags.filter((t) => t.id !== id); + }); + + // Add event + MOCK_EVENTS.unshift({ + id: generateId(), + timestamp: new Date().toISOString(), + level: "info", + target: "tag.deleted", + message: `Deleted tag: ${tag.name}`, + metadata: { tagId: id, tagName: tag.name }, + }); +} + +// Admin Events API +export async function getAdminEvents(filters?: { + level?: string; + target?: string; + limit?: number; +}): Promise { + // TODO: Replace with apiFetch('/admin/api/events?...') when backend ready + await new Promise((resolve) => setTimeout(resolve, 100)); + + let events = [...MOCK_EVENTS]; + + if (filters?.level) { + events = events.filter((e) => e.level === filters.level); + } + + if (filters?.target) { + events = events.filter((e) => e.target.includes(filters.target!)); + } + + if (filters?.limit) { + events = events.slice(0, filters.limit); + } + + return events; +} + +// Admin Stats API +export async function getAdminStats(): Promise { + // TODO: Replace with apiFetch('/admin/api/stats') when backend ready + await new Promise((resolve) => setTimeout(resolve, 80)); + + const projectsByStatus: Record = { + active: 0, + maintained: 0, + archived: 0, + hidden: 0, + }; + + MOCK_PROJECTS.forEach((p) => { + projectsByStatus[p.status]++; + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const eventsToday = MOCK_EVENTS.filter( + (e) => new Date(e.timestamp) >= today, + ).length; + + const errorsToday = MOCK_EVENTS.filter( + (e) => e.level === "error" && new Date(e.timestamp) >= today, + ).length; + + return { + totalProjects: MOCK_PROJECTS.length, + projectsByStatus: projectsByStatus as Record< + "active" | "maintained" | "archived" | "hidden", + number + >, + totalTags: MOCK_TAGS.length, + eventsToday, + errorsToday, + }; +} + +// Settings API +export async function getSettings(): Promise { + // TODO: Replace with apiFetch('/admin/api/settings') when backend ready + await new Promise((resolve) => setTimeout(resolve, 100)); + return structuredClone(MOCK_SETTINGS); +} + +export async function updateSettings(settings: SiteSettings): Promise { + // TODO: Replace with apiFetch('/admin/api/settings', { method: 'PUT', body: JSON.stringify(settings) }) + await new Promise((resolve) => setTimeout(resolve, 200)); + + MOCK_SETTINGS = structuredClone(settings); + + // Add event + MOCK_EVENTS.unshift({ + id: generateId(), + timestamp: new Date().toISOString(), + level: "info", + target: "settings.updated", + message: "Site settings updated", + metadata: {}, + }); + + return structuredClone(MOCK_SETTINGS); } diff --git a/web/src/lib/components/AppWrapper.svelte b/web/src/lib/components/AppWrapper.svelte index 9557f59..1ec3db0 100644 --- a/web/src/lib/components/AppWrapper.svelte +++ b/web/src/lib/components/AppWrapper.svelte @@ -6,15 +6,17 @@ let { class: className = "", backgroundClass = "", + bgColor = "bg-black", children, }: { class?: string; backgroundClass?: string; + bgColor?: string; children?: Snippet; } = $props(); -
+
{#if children} diff --git a/web/src/lib/components/admin/Badge.svelte b/web/src/lib/components/admin/Badge.svelte new file mode 100644 index 0000000..b6957f7 --- /dev/null +++ b/web/src/lib/components/admin/Badge.svelte @@ -0,0 +1,30 @@ + + + + {@render children?.()} + diff --git a/web/src/lib/components/admin/Button.svelte b/web/src/lib/components/admin/Button.svelte new file mode 100644 index 0000000..eedfdff --- /dev/null +++ b/web/src/lib/components/admin/Button.svelte @@ -0,0 +1,64 @@ + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/web/src/lib/components/admin/EventLog.svelte b/web/src/lib/components/admin/EventLog.svelte new file mode 100644 index 0000000..7a860a0 --- /dev/null +++ b/web/src/lib/components/admin/EventLog.svelte @@ -0,0 +1,94 @@ + + + +
+ {#each events as event} + {@const levelColors = { + info: "text-cyan-500/60", + warning: "text-amber-500/70", + error: "text-rose-500/70" + }} + {@const levelLabels = { + info: "INFO", + warning: "WARN", + error: "ERR" + }} +
+
+
+
+ + {levelLabels[event.level]} + + + {event.message} + + + target={event.target} + +
+
+ {#if showMetadata && event.metadata} + + {/if} + + {formatTimestamp(event.timestamp)} + +
+
+
+ {#if showMetadata && expandedEventId === event.id && event.metadata} +
+
+

Metadata:

+
{JSON.stringify(event.metadata, null, 2)}
+
+
+ {/if} +
+ {/each} +
+
diff --git a/web/src/lib/components/admin/Input.svelte b/web/src/lib/components/admin/Input.svelte new file mode 100644 index 0000000..4b9b33d --- /dev/null +++ b/web/src/lib/components/admin/Input.svelte @@ -0,0 +1,103 @@ + + +
+ {#if label} + + {/if} + + {#if type === "textarea"} + + {:else if type === "select"} + + {:else} + + {/if} + + {#if error} +

{error}

+ {/if} + + {#if help && !error} +

{help}

+ {/if} +
diff --git a/web/src/lib/components/admin/Modal.svelte b/web/src/lib/components/admin/Modal.svelte new file mode 100644 index 0000000..6ec6e4c --- /dev/null +++ b/web/src/lib/components/admin/Modal.svelte @@ -0,0 +1,84 @@ + + +{#if open} + +{/if} diff --git a/web/src/lib/components/admin/ProjectForm.svelte b/web/src/lib/components/admin/ProjectForm.svelte new file mode 100644 index 0000000..b933c43 --- /dev/null +++ b/web/src/lib/components/admin/ProjectForm.svelte @@ -0,0 +1,206 @@ + + +
+ +
+ + + +
+ + + + + +
+ + + +
+ + +
+ + + +
+ + + + + + + + +
+ + +

+ Media upload functionality will be available soon +

+
+ + +
+ + +
+ diff --git a/web/src/lib/components/admin/Sidebar.svelte b/web/src/lib/components/admin/Sidebar.svelte new file mode 100644 index 0000000..6a76ed6 --- /dev/null +++ b/web/src/lib/components/admin/Sidebar.svelte @@ -0,0 +1,131 @@ + + + + + + + + + +{#if mobileMenuOpen} +
(mobileMenuOpen = false)} + >
+{/if} diff --git a/web/src/lib/components/admin/Table.svelte b/web/src/lib/components/admin/Table.svelte new file mode 100644 index 0000000..62c089b --- /dev/null +++ b/web/src/lib/components/admin/Table.svelte @@ -0,0 +1,16 @@ + + +
+ + {@render children?.()} +
+
diff --git a/web/src/lib/components/admin/TagPicker.svelte b/web/src/lib/components/admin/TagPicker.svelte new file mode 100644 index 0000000..57c7710 --- /dev/null +++ b/web/src/lib/components/admin/TagPicker.svelte @@ -0,0 +1,123 @@ + + +
+ {#if label} + + {/if} + +
+ +
+
+ {#each selectedTags as tag} + + {tag.name} + + + {/each} + + + +
+
+ + + {#if dropdownOpen && filteredTags.length > 0} +
+ {#each filteredTags as tag} + + {/each} +
+ {/if} +
+ +

+ {selectedTagIds.length} tag{selectedTagIds.length === 1 ? "" : "s"} selected +

+
diff --git a/web/src/lib/stores/auth.svelte.ts b/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 0000000..fe779b4 --- /dev/null +++ b/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,90 @@ +// Mock admin authentication store +// TODO: Replace with real backend authentication when ready + +import type { AuthSession } from "$lib/admin-types"; + +const SESSION_KEY = "admin_session"; +const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +// Mock credentials (replace with backend auth) +const MOCK_USERNAME = "admin"; +const MOCK_PASSWORD = "password"; + +class AuthStore { + private session = $state(null); + private initialized = $state(false); + + constructor() { + // Initialize from localStorage when the store is created + if (typeof window !== "undefined") { + this.loadSession(); + } + } + + get isAuthenticated(): boolean { + if (!this.session) return false; + + const expiresAt = new Date(this.session.expiresAt); + const now = new Date(); + + if (now > expiresAt) { + this.logout(); + return false; + } + + return true; + } + + get isInitialized(): boolean { + return this.initialized; + } + + private loadSession() { + try { + const stored = localStorage.getItem(SESSION_KEY); + if (stored) { + const session = JSON.parse(stored) as AuthSession; + this.session = session; + } + } catch (error) { + console.error("Failed to load session:", error); + } finally { + this.initialized = true; + } + } + + private saveSession() { + if (this.session) { + localStorage.setItem(SESSION_KEY, JSON.stringify(this.session)); + } else { + localStorage.removeItem(SESSION_KEY); + } + } + + async login(username: string, password: string): Promise { + // TODO: Replace with real API call to /admin/api/login + await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay + + if (username === MOCK_USERNAME && password === MOCK_PASSWORD) { + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS); + + this.session = { + token: `mock-token-${Date.now()}`, + expiresAt: expiresAt.toISOString(), + }; + + this.saveSession(); + return true; + } + + return false; + } + + logout() { + this.session = null; + this.saveSession(); + } +} + +export const authStore = new AuthStore(); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index da53d00..155bd47 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -2,7 +2,10 @@ import "@fontsource-variable/inter/wght.css"; import "@fontsource/hanken-grotesk/900.css"; import "@fontsource-variable/schibsted-grotesk/wght.css"; + import "overlayscrollbars/overlayscrollbars.css"; import "../app.css"; + import { OverlayScrollbars } from "overlayscrollbars"; + import { onMount } from "svelte"; let { children, data } = $props(); @@ -15,6 +18,20 @@ }; const metadata = $derived(data?.metadata ?? defaultMetadata); + + onMount(() => { + // Initialize overlay scrollbars on the body element + const osInstance = OverlayScrollbars(document.body, { + scrollbars: { + autoHide: "leave", + autoHideDelay: 800, + }, + }); + + return () => { + osInstance?.destroy(); + }; + }); diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..72b1e6b --- /dev/null +++ b/web/src/routes/admin/+layout.svelte @@ -0,0 +1,63 @@ + + +{#if isLoginPage} + + {@render children()} +{:else} + + + + + +
+
+ {@render children()} +
+
+
+{/if} diff --git a/web/src/routes/admin/+layout.ts b/web/src/routes/admin/+layout.ts new file mode 100644 index 0000000..6d8e604 --- /dev/null +++ b/web/src/routes/admin/+layout.ts @@ -0,0 +1,31 @@ +import { browser } from "$app/environment"; +import { goto } from "$app/navigation"; +import { authStore } from "$lib/stores/auth.svelte"; + +export const ssr = false; // Admin is client-side only + +export async function load({ url }) { + if (browser) { + // Wait for auth store to initialize + while (!authStore.isInitialized) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + // Allow access to login page without authentication + if (url.pathname === "/admin/login") { + // If already authenticated, redirect to dashboard + if (authStore.isAuthenticated) { + goto("/admin"); + } + return {}; + } + + // Require authentication for all other admin pages + if (!authStore.isAuthenticated) { + goto("/admin/login"); + return {}; + } + } + + return {}; +} diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte new file mode 100644 index 0000000..4198003 --- /dev/null +++ b/web/src/routes/admin/+page.svelte @@ -0,0 +1,83 @@ + + + + Dashboard | Admin + + +
+ +
+

Dashboard

+

+ Overview of your portfolio and recent activity +

+
+ + {#if loading} +
+ Loading dashboard... +
+ {:else} + +
+ + + + +
+ + +
+
+

Recent Events

+ + View all → + +
+ + {#if recentEvents.length === 0} +

+ No events yet +

+ {:else} + + {/if} +
+ {/if} +
diff --git a/web/src/routes/admin/events/+page.svelte b/web/src/routes/admin/events/+page.svelte new file mode 100644 index 0000000..980425f --- /dev/null +++ b/web/src/routes/admin/events/+page.svelte @@ -0,0 +1,100 @@ + + + + Events | Admin + + +
+ +
+

Event Log

+

+ System activity, errors, and sync operations +

+
+ + +
+

Filters

+
+ + +
+
+ + + {#if loading} +
+ Loading events... +
+ {:else if events.length === 0} +
+

No events found

+
+ {:else} +
+
+

+ Event Log + + ({events.length} event{events.length === 1 ? "" : "s"}) + +

+
+ +
+ {/if} +
diff --git a/web/src/routes/admin/login/+page.svelte b/web/src/routes/admin/login/+page.svelte new file mode 100644 index 0000000..bf145cc --- /dev/null +++ b/web/src/routes/admin/login/+page.svelte @@ -0,0 +1,99 @@ + + + + Admin Login | xevion.dev + + + +
+
+ +
+
+ + + + + {#if error} +
+ {error} +
+ {/if} + +
+ Mock credentials: admin / password +
+ + +
+
+ + + +
+
+
diff --git a/web/src/routes/admin/projects/+page.svelte b/web/src/routes/admin/projects/+page.svelte new file mode 100644 index 0000000..95d683f --- /dev/null +++ b/web/src/routes/admin/projects/+page.svelte @@ -0,0 +1,208 @@ + + + + Projects | Admin + + +
+ +
+
+

Projects

+

+ Manage your project portfolio +

+
+ +
+ + + {#if loading} +
+ Loading projects... +
+ {:else if projects.length === 0} +
+

No projects yet

+ +
+ {:else} + + + + + + + + + + + + + {#each projects as project} + + + + + + + + + {/each} + +
+ Title + + Status + + Tags + + Priority + + Updated + + Actions +
+
+
+
+ {project.title} +
+
+ {project.slug} +
+
+
+
+ + {project.status} + + +
+ {#each project.tags.slice(0, 3) as tag} + {tag.name} + {/each} + {#if project.tags.length > 3} + +{project.tags.length - 3} + {/if} +
+
+ {project.priority} + + {formatDate(project.updatedAt)} + +
+ + +
+
+ {/if} +
+ + + + {#if deleteTarget} +
+

{deleteTarget.title}

+

{deleteTarget.slug}

+
+ {/if} +
diff --git a/web/src/routes/admin/projects/[id]/+page.svelte b/web/src/routes/admin/projects/[id]/+page.svelte new file mode 100644 index 0000000..772f45b --- /dev/null +++ b/web/src/routes/admin/projects/[id]/+page.svelte @@ -0,0 +1,86 @@ + + + + Edit Project | Admin + + +
+ +
+

Edit Project

+

+ Update project details and settings +

+
+ + + {#if loading} +
+ Loading... +
+ {:else if !project} +
+

Project not found

+ + ← Back to projects + +
+ {:else} +
+ +
+ {/if} +
diff --git a/web/src/routes/admin/projects/new/+page.svelte b/web/src/routes/admin/projects/new/+page.svelte new file mode 100644 index 0000000..ba13074 --- /dev/null +++ b/web/src/routes/admin/projects/new/+page.svelte @@ -0,0 +1,63 @@ + + + + New Project | Admin + + +
+ +
+

Create Project

+

+ Add a new project to your portfolio +

+
+ + + {#if loading} +
+ Loading... +
+ {:else} +
+ +
+ {/if} +
diff --git a/web/src/routes/admin/settings/+page.svelte b/web/src/routes/admin/settings/+page.svelte new file mode 100644 index 0000000..c82b92e --- /dev/null +++ b/web/src/routes/admin/settings/+page.svelte @@ -0,0 +1,268 @@ + + + + Settings | Admin + + +
+ +
+

Settings

+

+ Configure your site identity, social links, and admin preferences +

+
+ + {#if loading} +
Loading settings...
+ {:else if formData} + +
+ +
+ + +
+ {#if activeTab === "identity"} +
+

Site Identity

+ + + + +
+ {:else if activeTab === "social"} +
+

Social Links

+

+ Configure your social media presence on the index page +

+ +
+ {#each formData.socialLinks as link} + {@const Icon = getSocialIcon(link.platform)} +
+
+
+ +
+
+
+ {link.label} + +
+ +
+
+
+ {/each} +
+
+ {:else if activeTab === "admin"} +
+

Admin Preferences

+ + + +
+ {/if} +
+ + +
+ + +
+ {/if} +
diff --git a/web/src/routes/admin/tags/+page.svelte b/web/src/routes/admin/tags/+page.svelte new file mode 100644 index 0000000..c7bc531 --- /dev/null +++ b/web/src/routes/admin/tags/+page.svelte @@ -0,0 +1,322 @@ + + + + Tags | Admin + + +
+ +
+
+

Tags

+

+ Manage project tags and categories +

+
+ +
+ + + {#if showCreateForm} +
+

Create New Tag

+
+ + +
+
+ + +
+
+ {/if} + + + {#if loading} +
+ Loading tags... +
+ {:else if tags.length === 0} +
+

No tags yet

+ +
+ {:else} + + + + + + + + + + + {#each tags as tag} + + {#if editingId === tag.id} + + + + + + {:else} + + + + + + {/if} + + {/each} + +
+ Name + + Slug + + Projects + + Actions +
+ + + + + {tag.projectCount} + +
+ + +
+
+ {tag.name} + + {tag.slug} + + {tag.projectCount} + +
+ + +
+
+ {/if} +
+ + + + {#if deleteTarget} +
+

{deleteTarget.name}

+

+ Used in {deleteTarget.projectCount} project{deleteTarget.projectCount === 1 ? "" : "s"} +

+
+ {/if} +
diff --git a/web/src/routes/internal/health/+server.ts b/web/src/routes/internal/health/+server.ts index 5c3c261..7a5c3fe 100644 --- a/web/src/routes/internal/health/+server.ts +++ b/web/src/routes/internal/health/+server.ts @@ -1,5 +1,5 @@ import type { RequestHandler } from "./$types"; -import { apiFetch } from "$lib/api"; +import { apiFetch } from "$lib/api.server"; import { getLogger } from "@logtape/logtape"; const logger = getLogger(["ssr", "routes", "internal", "health"]); diff --git a/web/src/routes/internal/ogp/generate/+server.ts b/web/src/routes/internal/ogp/generate/+server.ts index dff132a..81bde8d 100644 --- a/web/src/routes/internal/ogp/generate/+server.ts +++ b/web/src/routes/internal/ogp/generate/+server.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from "./$types"; import type { OGImageSpec } from "$lib/og-types"; import { loadOGFonts } from "$lib/og-fonts"; -import { apiFetch } from "$lib/api"; +import { apiFetch } from "$lib/api.server"; import type { Project } from "../../../projects/+page.server"; import { getLogger } from "@logtape/logtape"; import satori from "satori"; diff --git a/web/src/routes/projects/+page.server.ts b/web/src/routes/projects/+page.server.ts index 9f3bfae..88f5db5 100644 --- a/web/src/routes/projects/+page.server.ts +++ b/web/src/routes/projects/+page.server.ts @@ -1,5 +1,5 @@ import type { PageServerLoad } from "./$types"; -import { apiFetch } from "$lib/api"; +import { apiFetch } from "$lib/api.server"; import { getOGImageUrl } from "$lib/og-types"; interface ProjectLink { @@ -9,6 +9,7 @@ interface ProjectLink { export interface Project { id: string; + slug: string; name: string; shortDescription: string; icon?: string;