mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 10:26:52 -06:00
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
This commit is contained in:
+2
-3
@@ -125,8 +125,7 @@ pub fn is_malicious_path(path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Admin panels
|
// Admin panels
|
||||||
if path_lower.starts_with("/admin")
|
if path_lower.starts_with("/administrator")
|
||||||
|| path_lower.starts_with("/administrator")
|
|
||||||
|| path_lower.contains("phpmyadmin")
|
|| path_lower.contains("phpmyadmin")
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -473,7 +472,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_admin_panels() {
|
fn test_admin_panels() {
|
||||||
assert!(is_malicious_path("/admin"));
|
assert!(!is_malicious_path("/admin"));
|
||||||
assert!(is_malicious_path("/administrator"));
|
assert!(is_malicious_path("/administrator"));
|
||||||
assert!(is_malicious_path("/phpmyadmin"));
|
assert!(is_malicious_path("/phpmyadmin"));
|
||||||
assert!(is_malicious_path("/phpMyAdmin"));
|
assert!(is_malicious_path("/phpMyAdmin"));
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@xevion/satori-html": "^0.4.1",
|
"@xevion/satori-html": "^0.4.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"overlayscrollbars": "^2.13.0",
|
||||||
|
"overlayscrollbars-svelte": "^0.5.5",
|
||||||
"satori": "^0.18.3",
|
"satori": "^0.18.3",
|
||||||
"tailwind-merge": "^3.3.1",
|
"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=="],
|
"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-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=="],
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@xevion/satori-html": "^0.4.1",
|
"@xevion/satori-html": "^0.4.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"overlayscrollbars": "^2.13.0",
|
||||||
|
"overlayscrollbars-svelte": "^0.5.5",
|
||||||
"satori": "^0.18.3",
|
"satori": "^0.18.3",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,32 @@
|
|||||||
--animate-title: title 3s ease-out forwards;
|
--animate-title: title 3s ease-out forwards;
|
||||||
--animate-fade-left: fade-left 3s ease-in-out forwards;
|
--animate-fade-left: fade-left 3s ease-in-out forwards;
|
||||||
--animate-fade-right: fade-right 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 {
|
@keyframes fade {
|
||||||
@@ -106,3 +132,15 @@ body {
|
|||||||
body {
|
body {
|
||||||
@apply h-full;
|
@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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
totalProjects: number;
|
||||||
|
projectsByStatus: Record<ProjectStatus, number>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+747
-48
@@ -1,64 +1,763 @@
|
|||||||
import { getLogger } from "@logtape/logtape";
|
import type {
|
||||||
import { env } from "$env/dynamic/private";
|
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;
|
// Mock data storage (in-memory for now)
|
||||||
const isUnixSocket =
|
let MOCK_TAGS: AdminTag[] = [
|
||||||
upstreamUrl?.startsWith("/") || upstreamUrl?.startsWith("./");
|
{ id: "tag-1", slug: "rust", name: "Rust", createdAt: "2024-01-15T10:00:00Z" },
|
||||||
const baseUrl = isUnixSocket ? "http://localhost" : upstreamUrl;
|
{ 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<T>(
|
let MOCK_PROJECTS: AdminProject[] = [
|
||||||
path: string,
|
{
|
||||||
init?: RequestInit,
|
id: "proj-1",
|
||||||
): Promise<T> {
|
slug: "portfolio-site",
|
||||||
if (!upstreamUrl) {
|
title: "Portfolio Site",
|
||||||
logger.error("UPSTREAM_URL environment variable not set");
|
description: "Personal portfolio with project showcase and blog",
|
||||||
throw new Error("UPSTREAM_URL environment variable not set");
|
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]],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${baseUrl}${path}`;
|
let MOCK_SETTINGS: SiteSettings = {
|
||||||
const method = init?.method ?? "GET";
|
identity: {
|
||||||
|
displayName: "Ryan Walters",
|
||||||
const fetchOptions: RequestInit & { unix?: string } = {
|
occupation: "Full-Stack Software Engineer",
|
||||||
...init,
|
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.",
|
||||||
signal: init?.signal ?? AbortSignal.timeout(30_000),
|
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",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isUnixSocket) {
|
function generateId(): string {
|
||||||
fetchOptions.unix = upstreamUrl;
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("API request", {
|
function slugify(text: string): string {
|
||||||
method,
|
return text
|
||||||
url,
|
.toLowerCase()
|
||||||
path,
|
.replace(/[^\w\s-]/g, "")
|
||||||
isUnixSocket,
|
.replace(/[\s_-]+/g, "-")
|
||||||
upstreamUrl,
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Projects API
|
||||||
|
export async function getAdminProjects(): Promise<AdminProject[]> {
|
||||||
|
// 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<AdminProject | null> {
|
||||||
|
// 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<AdminProject> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
MOCK_PROJECTS.push(newProject);
|
||||||
|
|
||||||
|
// 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 {
|
return newProject;
|
||||||
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();
|
export async function updateAdminProject(
|
||||||
logger.debug("API response", { method, url, status: response.status });
|
data: UpdateProjectData,
|
||||||
return data;
|
): Promise<AdminProject> {
|
||||||
} catch (error) {
|
// TODO: Replace with apiFetch(`/admin/api/projects/${data.id}`, { method: 'PUT', body: JSON.stringify(data) })
|
||||||
logger.error("API request exception", {
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
method,
|
|
||||||
url,
|
const index = MOCK_PROJECTS.findIndex((p) => p.id === data.id);
|
||||||
error: error instanceof Error ? error.message : String(error),
|
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 },
|
||||||
});
|
});
|
||||||
throw error;
|
|
||||||
|
return updatedProject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminProject(id: string): Promise<void> {
|
||||||
|
// 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<AdminTagWithCount[]> {
|
||||||
|
// 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<AdminTag> {
|
||||||
|
// 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<AdminTag> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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<AdminEvent[]> {
|
||||||
|
// 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<AdminStats> {
|
||||||
|
// TODO: Replace with apiFetch('/admin/api/stats') when backend ready
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
|
||||||
|
const projectsByStatus: Record<string, number> = {
|
||||||
|
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<SiteSettings> {
|
||||||
|
// 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<SiteSettings> {
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,17 @@
|
|||||||
let {
|
let {
|
||||||
class: className = "",
|
class: className = "",
|
||||||
backgroundClass = "",
|
backgroundClass = "",
|
||||||
|
bgColor = "bg-black",
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
class?: string;
|
class?: string;
|
||||||
backgroundClass?: string;
|
backgroundClass?: string;
|
||||||
|
bgColor?: string;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 -z-20 bg-black"></div>
|
<div class={cn("pointer-events-none fixed inset-0 -z-20", bgColor)}></div>
|
||||||
<Dots class={[backgroundClass]} />
|
<Dots class={[backgroundClass]} />
|
||||||
<main class={cn("relative min-h-screen text-zinc-50", className)}>
|
<main class={cn("relative min-h-screen text-zinc-50", className)}>
|
||||||
{#if children}
|
{#if children}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import type { ProjectStatus, EventLevel } from "$lib/admin-types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: ProjectStatus | EventLevel | "default";
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { variant = "default", class: className, children }: Props = $props();
|
||||||
|
|
||||||
|
const baseStyles =
|
||||||
|
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium";
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
active: "bg-emerald-500/10 text-emerald-400",
|
||||||
|
maintained: "bg-indigo-500/10 text-indigo-400",
|
||||||
|
archived: "bg-zinc-500/10 text-zinc-400",
|
||||||
|
hidden: "bg-zinc-500/10 text-zinc-500",
|
||||||
|
info: "bg-teal-500/10 text-teal-400",
|
||||||
|
warning: "bg-amber-500/10 text-amber-400",
|
||||||
|
error: "bg-red-500/10 text-red-400",
|
||||||
|
default: "bg-zinc-500/10 text-zinc-400",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class={cn(baseStyles, variantStyles[variant], className)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
href?: string;
|
||||||
|
onclick?: () => void;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
class: className,
|
||||||
|
href,
|
||||||
|
onclick,
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const baseStyles =
|
||||||
|
"inline-flex items-center justify-center font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-admin-bg disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
primary:
|
||||||
|
"bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:ring-indigo-500 shadow-sm hover:shadow",
|
||||||
|
secondary:
|
||||||
|
"bg-transparent text-admin-text border border-zinc-700 hover:border-zinc-600 hover:bg-zinc-800/50 focus-visible:ring-zinc-500",
|
||||||
|
danger:
|
||||||
|
"bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-500 shadow-sm hover:shadow",
|
||||||
|
ghost:
|
||||||
|
"text-admin-text hover:bg-zinc-800/50 focus-visible:ring-zinc-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: "h-8 px-3 text-sm rounded",
|
||||||
|
md: "h-9 px-4 text-sm rounded-md",
|
||||||
|
lg: "h-11 px-6 text-base rounded-md",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class={cn(baseStyles, variantStyles[variant], sizeStyles[size], "cursor-pointer", className)}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
class={cn(baseStyles, variantStyles[variant], sizeStyles[size], className)}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AdminEvent } from "$lib/admin-types";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-svelte";
|
||||||
|
import "overlayscrollbars/overlayscrollbars.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: AdminEvent[];
|
||||||
|
maxHeight?: string;
|
||||||
|
showMetadata?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { events, maxHeight = "400px", showMetadata = false }: Props = $props();
|
||||||
|
|
||||||
|
let expandedEventId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "just now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMetadata(eventId: string) {
|
||||||
|
expandedEventId = expandedEventId === eventId ? null : eventId;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
options={{
|
||||||
|
scrollbars: { autoHide: "leave", autoHideDelay: 800 }
|
||||||
|
}}
|
||||||
|
defer
|
||||||
|
style="max-height: {maxHeight}"
|
||||||
|
>
|
||||||
|
<div class="divide-y divide-zinc-800/50 bg-zinc-950">
|
||||||
|
{#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"
|
||||||
|
}}
|
||||||
|
<div class="hover:bg-zinc-900/50 transition-colors">
|
||||||
|
<div class="px-4 py-1.5">
|
||||||
|
<div class="flex items-center justify-between gap-4 text-xs">
|
||||||
|
<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`}>
|
||||||
|
{levelLabels[event.level]}
|
||||||
|
</span>
|
||||||
|
<span class="text-zinc-300 truncate">
|
||||||
|
{event.message}
|
||||||
|
</span>
|
||||||
|
<span class="text-zinc-500 shrink-0">
|
||||||
|
target=<span class="text-zinc-400">{event.target}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
|
{#if showMetadata && event.metadata}
|
||||||
|
<button
|
||||||
|
class="text-[11px] text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
onclick={() => toggleMetadata(event.id)}
|
||||||
|
>
|
||||||
|
{expandedEventId === event.id ? "hide" : "show"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<span class="text-zinc-600 text-[11px] tabular-nums">
|
||||||
|
{formatTimestamp(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if showMetadata && expandedEventId === event.id && event.metadata}
|
||||||
|
<div class="px-4 pb-2">
|
||||||
|
<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>
|
||||||
|
<pre class="text-zinc-400 overflow-x-auto">{JSON.stringify(event.metadata, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
type?: "text" | "number" | "email" | "password" | "url" | "textarea" | "select";
|
||||||
|
value: string | number;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
help?: string;
|
||||||
|
class?: string;
|
||||||
|
rows?: number;
|
||||||
|
options?: Array<{ value: string; label: string }>;
|
||||||
|
oninput?: (value: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
value = $bindable(""),
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
error,
|
||||||
|
help,
|
||||||
|
class: className,
|
||||||
|
rows = 4,
|
||||||
|
options = [],
|
||||||
|
oninput,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
const errorStyles = error
|
||||||
|
? "border-red-500 focus:border-red-500 focus:ring-red-500"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
||||||
|
const newValue = type === "number" ? Number(target.value) : target.value;
|
||||||
|
value = newValue;
|
||||||
|
oninput?.(newValue);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("space-y-1.5", className)}>
|
||||||
|
{#if label}
|
||||||
|
<label class="block text-sm font-medium text-admin-text">
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-red-500">*</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if type === "textarea"}
|
||||||
|
<textarea
|
||||||
|
bind:value
|
||||||
|
{placeholder}
|
||||||
|
{disabled}
|
||||||
|
{required}
|
||||||
|
{rows}
|
||||||
|
class={cn(inputStyles, errorStyles, "resize-y")}
|
||||||
|
oninput={handleInput}
|
||||||
|
></textarea>
|
||||||
|
{:else if type === "select"}
|
||||||
|
<select
|
||||||
|
bind:value
|
||||||
|
{disabled}
|
||||||
|
{required}
|
||||||
|
class={cn(inputStyles, errorStyles)}
|
||||||
|
onchange={handleInput}
|
||||||
|
>
|
||||||
|
{#if placeholder}
|
||||||
|
<option value="" disabled>{placeholder}</option>
|
||||||
|
{/if}
|
||||||
|
{#each options as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
{placeholder}
|
||||||
|
{disabled}
|
||||||
|
{required}
|
||||||
|
class={cn(inputStyles, errorStyles)}
|
||||||
|
oninput={handleInput}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-xs text-red-500">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if help && !error}
|
||||||
|
<p class="text-xs text-admin-text-muted">{help}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import Button from "./Button.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
confirmVariant?: "primary" | "danger";
|
||||||
|
onconfirm?: () => void;
|
||||||
|
oncancel?: () => void;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
title = "Confirm",
|
||||||
|
description,
|
||||||
|
confirmText = "Confirm",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
confirmVariant = "primary",
|
||||||
|
onconfirm,
|
||||||
|
oncancel,
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
open = false;
|
||||||
|
oncancel?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
open = false;
|
||||||
|
onconfirm?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-md rounded-xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl shadow-black/50"
|
||||||
|
>
|
||||||
|
{#if title}
|
||||||
|
<h2 class="text-lg font-semibold text-zinc-50 mb-2">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if description}
|
||||||
|
<p class="text-sm text-zinc-400 mb-4">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if children}
|
||||||
|
<div class="mb-4">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onclick={handleCancel}>
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
<Button variant={confirmVariant} onclick={handleConfirm}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "./Button.svelte";
|
||||||
|
import Input from "./Input.svelte";
|
||||||
|
import TagPicker from "./TagPicker.svelte";
|
||||||
|
import type { AdminProject, AdminTag, CreateProjectData, ProjectStatus } from "$lib/admin-types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project?: AdminProject | null;
|
||||||
|
availableTags: AdminTag[];
|
||||||
|
onsubmit: (data: CreateProjectData) => Promise<void>;
|
||||||
|
submitLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
project = null,
|
||||||
|
availableTags,
|
||||||
|
onsubmit,
|
||||||
|
submitLabel = "Save Project",
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let title = $state(project?.title ?? "");
|
||||||
|
let slug = $state(project?.slug ?? "");
|
||||||
|
let description = $state(project?.description ?? "");
|
||||||
|
let status = $state<ProjectStatus>(project?.status ?? "active");
|
||||||
|
let githubRepo = $state(project?.githubRepo ?? "");
|
||||||
|
let demoUrl = $state(project?.demoUrl ?? "");
|
||||||
|
let icon = $state(project?.icon ?? "");
|
||||||
|
let priority = $state(project?.priority ?? 0);
|
||||||
|
let selectedTagIds = $state<string[]>(project?.tags.map(t => t.id) ?? []);
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let slugTouched = $state(false);
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "maintained", label: "Maintained" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
{ value: "hidden", label: "Hidden" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Auto-generate slug placeholder from title
|
||||||
|
const slugPlaceholder = $derived(
|
||||||
|
title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.replace(/[\s_-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSlugInput(value: string | number) {
|
||||||
|
slugTouched = true;
|
||||||
|
slug = value as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onsubmit({
|
||||||
|
title,
|
||||||
|
slug: slug || slugPlaceholder,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
githubRepo: githubRepo || undefined,
|
||||||
|
demoUrl: demoUrl || undefined,
|
||||||
|
icon: icon || undefined,
|
||||||
|
priority,
|
||||||
|
tagIds: selectedTagIds,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to submit project:", error);
|
||||||
|
alert("Failed to save project");
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
<!-- Title & Slug -->
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
required
|
||||||
|
placeholder="My Awesome Project"
|
||||||
|
help="The display name of your project"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Slug"
|
||||||
|
type="text"
|
||||||
|
value={slug}
|
||||||
|
oninput={handleSlugInput}
|
||||||
|
placeholder={slugPlaceholder}
|
||||||
|
help="URL-friendly identifier (leave empty to auto-generate)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<Input
|
||||||
|
label="Description"
|
||||||
|
type="textarea"
|
||||||
|
bind:value={description}
|
||||||
|
required
|
||||||
|
rows={6}
|
||||||
|
placeholder="A brief description of your project..."
|
||||||
|
help="Plain text description (markdown not supported yet)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Status & Priority -->
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Status"
|
||||||
|
type="select"
|
||||||
|
bind:value={status}
|
||||||
|
options={statusOptions}
|
||||||
|
help="Project visibility and state"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Priority"
|
||||||
|
type="number"
|
||||||
|
bind:value={priority}
|
||||||
|
placeholder="0"
|
||||||
|
help="Higher numbers appear first (e.g., 100, 50, 10)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Links -->
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="GitHub Repository"
|
||||||
|
type="text"
|
||||||
|
bind:value={githubRepo}
|
||||||
|
placeholder="username/repo"
|
||||||
|
help="Format: owner/repo (e.g., facebook/react)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Demo URL"
|
||||||
|
type="url"
|
||||||
|
bind:value={demoUrl}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
help="Live demo or project website"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<Input
|
||||||
|
label="Icon"
|
||||||
|
type="text"
|
||||||
|
bind:value={icon}
|
||||||
|
placeholder="fa-rocket"
|
||||||
|
help="Font Awesome icon class (e.g., fa-rocket, fa-heart)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<TagPicker
|
||||||
|
label="Tags"
|
||||||
|
{availableTags}
|
||||||
|
bind:selectedTagIds
|
||||||
|
placeholder="Search and select tags..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Media Upload Placeholder -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="block text-sm font-medium text-admin-text">
|
||||||
|
Media
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-upload mr-2"></i>
|
||||||
|
Upload Images/Videos (Coming Soon)
|
||||||
|
</Button>
|
||||||
|
<p class="text-xs text-admin-text-muted">
|
||||||
|
Media upload functionality will be available soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
href="/admin/projects"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={submitting || !title}
|
||||||
|
>
|
||||||
|
{submitting ? "Saving..." : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import IconLayoutDashboard from "~icons/lucide/layout-dashboard";
|
||||||
|
import IconFolder from "~icons/lucide/folder";
|
||||||
|
import IconTags from "~icons/lucide/tags";
|
||||||
|
import IconList from "~icons/lucide/list";
|
||||||
|
import IconSettings from "~icons/lucide/settings";
|
||||||
|
import IconArrowLeft from "~icons/lucide/arrow-left";
|
||||||
|
import IconLogOut from "~icons/lucide/log-out";
|
||||||
|
import IconMenu from "~icons/lucide/menu";
|
||||||
|
import IconX from "~icons/lucide/x";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectCount?: number;
|
||||||
|
tagCount?: number;
|
||||||
|
onlogout?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { projectCount = 0, tagCount = 0, onlogout }: Props = $props();
|
||||||
|
|
||||||
|
let mobileMenuOpen = $state(false);
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: any;
|
||||||
|
badge?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ href: "/admin", label: "Dashboard", icon: IconLayoutDashboard },
|
||||||
|
{ href: "/admin/projects", label: "Projects", icon: IconFolder, badge: projectCount },
|
||||||
|
{ href: "/admin/tags", label: "Tags", icon: IconTags, badge: tagCount },
|
||||||
|
{ href: "/admin/events", label: "Events", icon: IconList },
|
||||||
|
{ href: "/admin/settings", label: "Settings", icon: IconSettings },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pathname = $derived($page.url.pathname as string);
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (href === "/admin") {
|
||||||
|
return pathname === "/admin";
|
||||||
|
}
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
onlogout?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<button
|
||||||
|
class="fixed top-4 right-4 z-50 lg:hidden rounded-md bg-zinc-900 p-2 text-zinc-200 border border-zinc-800"
|
||||||
|
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{#if mobileMenuOpen}
|
||||||
|
<IconX class="w-5 h-5" />
|
||||||
|
{:else}
|
||||||
|
<IconMenu class="w-5 h-5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
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",
|
||||||
|
mobileMenuOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="border-b border-zinc-800 px-4 py-5">
|
||||||
|
<h1 class="text-base font-semibold text-zinc-50">
|
||||||
|
xevion.dev
|
||||||
|
<span class="text-xs font-normal text-zinc-500 ml-1.5">Admin</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 space-y-0.5 p-3">
|
||||||
|
{#each navItems as item}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class={cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all relative",
|
||||||
|
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"
|
||||||
|
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon class="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span class="flex-1">{item.label}</span>
|
||||||
|
{#if item.badge}
|
||||||
|
<span class="text-xs text-zinc-500">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Bottom actions -->
|
||||||
|
<div class="space-y-0.5 border-t border-zinc-800 bg-zinc-900/50 p-3">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-zinc-400 transition-all hover:text-zinc-200 hover:bg-zinc-800/30"
|
||||||
|
>
|
||||||
|
<IconArrowLeft class="w-4 h-4" />
|
||||||
|
<span>Back to Site</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onclick={handleLogout}
|
||||||
|
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-zinc-400 transition-all hover:text-zinc-200 hover:bg-zinc-800/30"
|
||||||
|
>
|
||||||
|
<IconLogOut class="w-4 h-4" />
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Backdrop for mobile -->
|
||||||
|
{#if mobileMenuOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||||
|
onclick={() => (mobileMenuOpen = false)}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
children?: import("svelte").Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("overflow-x-auto rounded-lg border border-admin-border", className)}>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
{@render children?.()}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import type { AdminTag } from "$lib/admin-types";
|
||||||
|
import IconX from "~icons/lucide/x";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
availableTags: AdminTag[];
|
||||||
|
selectedTagIds: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
availableTags,
|
||||||
|
selectedTagIds = $bindable([]),
|
||||||
|
placeholder = "Search tags...",
|
||||||
|
class: className,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let searchTerm = $state("");
|
||||||
|
let dropdownOpen = $state(false);
|
||||||
|
let inputRef: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
|
const selectedTags = $derived(
|
||||||
|
availableTags.filter((tag) => selectedTagIds.includes(tag.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTags = $derived(
|
||||||
|
availableTags.filter(
|
||||||
|
(tag) =>
|
||||||
|
!selectedTagIds.includes(tag.id) &&
|
||||||
|
tag.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function addTag(tagId: string) {
|
||||||
|
selectedTagIds = [...selectedTagIds, tagId];
|
||||||
|
searchTerm = "";
|
||||||
|
dropdownOpen = false;
|
||||||
|
inputRef?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tagId: string) {
|
||||||
|
selectedTagIds = selectedTagIds.filter((id) => id !== tagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputFocus() {
|
||||||
|
dropdownOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputBlur() {
|
||||||
|
setTimeout(() => {
|
||||||
|
dropdownOpen = false;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("space-y-1.5", className)}>
|
||||||
|
{#if label}
|
||||||
|
<label class="block text-sm font-medium text-admin-text">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Selected tags display -->
|
||||||
|
<div
|
||||||
|
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">
|
||||||
|
{#each selectedTags as tag}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeTag(tag.id)}
|
||||||
|
class="hover:text-blue-300"
|
||||||
|
aria-label="Remove tag"
|
||||||
|
>
|
||||||
|
<IconX class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<input
|
||||||
|
bind:this={inputRef}
|
||||||
|
type="text"
|
||||||
|
bind:value={searchTerm}
|
||||||
|
{placeholder}
|
||||||
|
class="flex-1 bg-transparent text-sm text-admin-text placeholder:text-admin-text-muted focus:outline-none min-w-[120px]"
|
||||||
|
onfocus={handleInputFocus}
|
||||||
|
onblur={handleInputBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
{#if dropdownOpen && filteredTags.length > 0}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{#each filteredTags as tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm text-admin-text hover:bg-admin-hover transition-colors"
|
||||||
|
onclick={() => addTag(tag.id)}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-admin-text-muted">
|
||||||
|
{selectedTagIds.length} tag{selectedTagIds.length === 1 ? "" : "s"} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -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<AuthSession | null>(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<boolean> {
|
||||||
|
// 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();
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
import "@fontsource-variable/inter/wght.css";
|
import "@fontsource-variable/inter/wght.css";
|
||||||
import "@fontsource/hanken-grotesk/900.css";
|
import "@fontsource/hanken-grotesk/900.css";
|
||||||
import "@fontsource-variable/schibsted-grotesk/wght.css";
|
import "@fontsource-variable/schibsted-grotesk/wght.css";
|
||||||
|
import "overlayscrollbars/overlayscrollbars.css";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
|
import { OverlayScrollbars } from "overlayscrollbars";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
@@ -15,6 +18,20 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const metadata = $derived(data?.metadata ?? defaultMetadata);
|
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();
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import Sidebar from "$lib/components/admin/Sidebar.svelte";
|
||||||
|
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import { getAdminStats } from "$lib/api";
|
||||||
|
import type { AdminStats } from "$lib/admin-types";
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
let stats = $state<AdminStats | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
const pathname = $derived($page.url.pathname as string);
|
||||||
|
const isLoginPage = $derived(pathname === "/admin/login");
|
||||||
|
|
||||||
|
// Load stats for sidebar badges
|
||||||
|
async function loadStats() {
|
||||||
|
if (isLoginPage || !authStore.isAuthenticated) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stats = await getAdminStats();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load stats:", error);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load stats when component mounts or when authentication changes
|
||||||
|
$effect(() => {
|
||||||
|
if (authStore.isAuthenticated && !isLoginPage) {
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
authStore.logout();
|
||||||
|
goto("/admin/login");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isLoginPage}
|
||||||
|
<!-- Login page has no sidebar -->
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<!-- Admin layout with sidebar and dots shader -->
|
||||||
|
<AppWrapper bgColor="bg-admin-bg">
|
||||||
|
<Sidebar
|
||||||
|
projectCount={stats?.totalProjects ?? 0}
|
||||||
|
tagCount={stats?.totalTags ?? 0}
|
||||||
|
onlogout={handleLogout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main content area -->
|
||||||
|
<main class="lg:pl-64">
|
||||||
|
<div class="px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AppWrapper>
|
||||||
|
{/if}
|
||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "$lib/components/admin/Button.svelte";
|
||||||
|
import EventLog from "$lib/components/admin/EventLog.svelte";
|
||||||
|
import { getAdminEvents } from "$lib/api";
|
||||||
|
import type { AdminEvent } from "$lib/admin-types";
|
||||||
|
import IconPlus from "~icons/lucide/plus";
|
||||||
|
|
||||||
|
let recentEvents = $state<AdminEvent[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const eventsData = await getAdminEvents({ limit: 10 });
|
||||||
|
recentEvents = eventsData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load dashboard:", error);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Dashboard | Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-zinc-50">Dashboard</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
Overview of your portfolio and recent activity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12 text-admin-text-muted">
|
||||||
|
Loading dashboard...
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button variant="primary" href="/admin/projects/new">
|
||||||
|
<IconPlus class="w-4 h-4 mr-2" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" href="/admin/projects">
|
||||||
|
View All Projects
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" href="/admin/tags">
|
||||||
|
Manage Tags
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" href="/admin/events">
|
||||||
|
View Events
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Events -->
|
||||||
|
<div 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>
|
||||||
|
<a
|
||||||
|
href="/admin/events"
|
||||||
|
class="text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recentEvents.length === 0}
|
||||||
|
<p class="text-sm text-zinc-500 text-center py-8">
|
||||||
|
No events yet
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<EventLog events={recentEvents} maxHeight="400px" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Input from "$lib/components/admin/Input.svelte";
|
||||||
|
import EventLog from "$lib/components/admin/EventLog.svelte";
|
||||||
|
import { getAdminEvents } from "$lib/api";
|
||||||
|
import type { AdminEvent } from "$lib/admin-types";
|
||||||
|
|
||||||
|
let events = $state<AdminEvent[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let filterLevel = $state<string>("");
|
||||||
|
let filterTarget = $state("");
|
||||||
|
|
||||||
|
const levelOptions = [
|
||||||
|
{ value: "", label: "All Levels" },
|
||||||
|
{ value: "info", label: "Info" },
|
||||||
|
{ value: "warning", label: "Warning" },
|
||||||
|
{ value: "error", label: "Error" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const filters: { level?: string; target?: string } = {};
|
||||||
|
if (filterLevel) filters.level = filterLevel;
|
||||||
|
if (filterTarget) filters.target = filterTarget;
|
||||||
|
|
||||||
|
events = await getAdminEvents(filters);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load events:", error);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload when filters change
|
||||||
|
$effect(() => {
|
||||||
|
filterLevel;
|
||||||
|
filterTarget;
|
||||||
|
loadEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Events | Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-zinc-50">Event Log</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
System activity, errors, and sync operations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<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>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Level"
|
||||||
|
type="select"
|
||||||
|
bind:value={filterLevel}
|
||||||
|
options={levelOptions}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Target"
|
||||||
|
type="text"
|
||||||
|
bind:value={filterTarget}
|
||||||
|
placeholder="e.g., project, tag, github"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Log -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12 text-zinc-500">
|
||||||
|
Loading events...
|
||||||
|
</div>
|
||||||
|
{:else if events.length === 0}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<p class="text-zinc-500">No events found</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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">
|
||||||
|
<h2 class="text-sm font-medium text-zinc-300">
|
||||||
|
Event Log
|
||||||
|
<span class="text-zinc-500 font-normal ml-2">
|
||||||
|
({events.length} event{events.length === 1 ? "" : "s"})
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<EventLog events={events} maxHeight="600px" showMetadata={true} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import Button from "$lib/components/admin/Button.svelte";
|
||||||
|
import Input from "$lib/components/admin/Input.svelte";
|
||||||
|
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
|
||||||
|
let username = $state("");
|
||||||
|
let password = $state("");
|
||||||
|
let error = $state("");
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = "";
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await authStore.login(username, password);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
goto("/admin");
|
||||||
|
} else {
|
||||||
|
error = "Invalid username or password";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = "An error occurred during login";
|
||||||
|
console.error("Login error:", err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin Login | xevion.dev</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<AppWrapper bgColor="bg-admin-bg">
|
||||||
|
<div class="flex min-h-screen items-center justify-center px-4">
|
||||||
|
<div class="w-full max-w-md space-y-4">
|
||||||
|
<!-- Login Form -->
|
||||||
|
<div
|
||||||
|
class="rounded-lg bg-admin-panel p-8 shadow-2xl shadow-zinc-500/20"
|
||||||
|
>
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
<Input
|
||||||
|
label="Username"
|
||||||
|
type="text"
|
||||||
|
bind:value={username}
|
||||||
|
placeholder="admin"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div
|
||||||
|
class="rounded-md bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-400"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="text-xs text-admin-text-muted">
|
||||||
|
Mock credentials: <code class="rounded bg-admin-bg px-1 py-0.5">admin</code> / <code class="rounded bg-admin-bg px-1 py-0.5">password</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
class="w-full"
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to site link -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="text-sm text-admin-text-muted hover:text-admin-text transition-colors"
|
||||||
|
>
|
||||||
|
← Back to site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppWrapper>
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "$lib/components/admin/Button.svelte";
|
||||||
|
import Table from "$lib/components/admin/Table.svelte";
|
||||||
|
import Badge from "$lib/components/admin/Badge.svelte";
|
||||||
|
import Modal from "$lib/components/admin/Modal.svelte";
|
||||||
|
import { getAdminProjects, deleteAdminProject } from "$lib/api";
|
||||||
|
import type { AdminProject } from "$lib/admin-types";
|
||||||
|
import IconPlus from "~icons/lucide/plus";
|
||||||
|
|
||||||
|
let projects = $state<AdminProject[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let deleteModalOpen = $state(false);
|
||||||
|
let deleteTarget = $state<AdminProject | null>(null);
|
||||||
|
let deleteConfirmReady = $state(false);
|
||||||
|
let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
projects = await getAdminProjects();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load projects:", error);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load projects on mount
|
||||||
|
$effect(() => {
|
||||||
|
loadProjects();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initiateDelete(project: AdminProject) {
|
||||||
|
deleteTarget = project;
|
||||||
|
deleteConfirmReady = false;
|
||||||
|
|
||||||
|
// Enable confirm button after delay
|
||||||
|
deleteTimeout = setTimeout(() => {
|
||||||
|
deleteConfirmReady = true;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
deleteModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
if (deleteTimeout) {
|
||||||
|
clearTimeout(deleteTimeout);
|
||||||
|
}
|
||||||
|
deleteModalOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
deleteConfirmReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget || !deleteConfirmReady) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAdminProject(deleteTarget.id);
|
||||||
|
projects = projects.filter((p) => p.id !== deleteTarget!.id);
|
||||||
|
deleteModalOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
deleteConfirmReady = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete project:", error);
|
||||||
|
alert("Failed to delete project");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Projects | Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-zinc-50">Projects</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
Manage your project portfolio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" href="/admin/projects/new">
|
||||||
|
<IconPlus class="w-4 h-4 mr-2" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects Table -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12 text-zinc-500">
|
||||||
|
Loading projects...
|
||||||
|
</div>
|
||||||
|
{:else if projects.length === 0}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<p class="text-zinc-500 mb-4">No projects yet</p>
|
||||||
|
<Button variant="primary" href="/admin/projects/new">Create your first project</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Table>
|
||||||
|
<thead class="bg-zinc-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
|
||||||
|
Title
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
|
||||||
|
Tags
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
|
||||||
|
Priority
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
|
||||||
|
Updated
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-zinc-500">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-800/50">
|
||||||
|
{#each projects as project}
|
||||||
|
<tr class="hover:bg-zinc-800/30 transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-zinc-200">
|
||||||
|
{project.title}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
{project.slug}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Badge variant={project.status}>
|
||||||
|
{project.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each project.tags.slice(0, 3) as tag}
|
||||||
|
<Badge variant="default">{tag.name}</Badge>
|
||||||
|
{/each}
|
||||||
|
{#if project.tags.length > 3}
|
||||||
|
<Badge variant="default">+{project.tags.length - 3}</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-300">
|
||||||
|
{project.priority}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-500 text-sm">
|
||||||
|
{formatDate(project.updatedAt)}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
href={`/admin/projects/${project.id}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => initiateDelete(project)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<Modal
|
||||||
|
bind:open={deleteModalOpen}
|
||||||
|
title="Delete Project"
|
||||||
|
description="Are you sure you want to delete this project? This action cannot be undone."
|
||||||
|
confirmText={deleteConfirmReady ? "Delete" : `Wait ${2}s...`}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={confirmDelete}
|
||||||
|
oncancel={cancelDelete}
|
||||||
|
>
|
||||||
|
{#if deleteTarget}
|
||||||
|
<div class="rounded-md bg-zinc-800/50 border border-zinc-700 p-3">
|
||||||
|
<p class="font-medium text-zinc-200">{deleteTarget.title}</p>
|
||||||
|
<p class="text-sm text-zinc-500">{deleteTarget.slug}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
||||||
|
import { getAdminProject, getAdminTags, updateAdminProject } from "$lib/api";
|
||||||
|
import type { AdminProject, AdminTag, AdminTagWithCount, CreateProjectData, UpdateProjectData } from "$lib/admin-types";
|
||||||
|
|
||||||
|
const projectId = $derived(($page.params as { id: string }).id);
|
||||||
|
|
||||||
|
let project = $state<AdminProject | null>(null);
|
||||||
|
let tags = $state<AdminTag[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [projectData, tagsWithCounts] = await Promise.all([
|
||||||
|
getAdminProject(projectId),
|
||||||
|
getAdminTags(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
project = projectData;
|
||||||
|
tags = tagsWithCounts.map((t: AdminTagWithCount): AdminTag => ({
|
||||||
|
id: t.id,
|
||||||
|
slug: t.slug,
|
||||||
|
name: t.name,
|
||||||
|
createdAt: t.createdAt
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load data:", error);
|
||||||
|
alert("Failed to load project");
|
||||||
|
goto("/admin/projects");
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(data: CreateProjectData) {
|
||||||
|
const updateData: UpdateProjectData = {
|
||||||
|
...data,
|
||||||
|
id: projectId,
|
||||||
|
};
|
||||||
|
await updateAdminProject(updateData);
|
||||||
|
goto("/admin/projects");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Edit Project | Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-3xl space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-admin-text">Edit Project</h1>
|
||||||
|
<p class="mt-1 text-sm text-admin-text-muted">
|
||||||
|
Update project details and settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12 text-admin-text-muted">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
{:else if !project}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<p class="text-admin-text-muted mb-4">Project not found</p>
|
||||||
|
<a href="/admin/projects" class="text-blue-400 hover:text-blue-300">
|
||||||
|
← Back to projects
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-lg border border-admin-border bg-admin-panel p-6">
|
||||||
|
<ProjectForm
|
||||||
|
{project}
|
||||||
|
availableTags={tags}
|
||||||
|
onsubmit={handleSubmit}
|
||||||
|
submitLabel="Update Project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
||||||
|
import { getAdminTags, createAdminProject } from "$lib/api";
|
||||||
|
import type { AdminTag, AdminTagWithCount, CreateProjectData } from "$lib/admin-types";
|
||||||
|
|
||||||
|
let tags = $state<AdminTag[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
try {
|
||||||
|
const tagsWithCounts = await getAdminTags();
|
||||||
|
tags = tagsWithCounts.map((t: AdminTagWithCount): AdminTag => ({
|
||||||
|
id: t.id,
|
||||||
|
slug: t.slug,
|
||||||
|
name: t.name,
|
||||||
|
createdAt: t.createdAt
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tags:", error);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadTags();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(data: CreateProjectData) {
|
||||||
|
await createAdminProject(data);
|
||||||
|
goto("/admin/projects");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>New Project | Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-3xl space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-admin-text">Create Project</h1>
|
||||||
|
<p class="mt-1 text-sm text-admin-text-muted">
|
||||||
|
Add a new project to your portfolio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12 text-admin-text-muted">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-lg border border-admin-border bg-admin-panel p-6">
|
||||||
|
<ProjectForm
|
||||||
|
availableTags={tags}
|
||||||
|
onsubmit={handleSubmit}
|
||||||
|
submitLabel="Create Project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "$lib/components/admin/Button.svelte";
|
||||||
|
import Input from "$lib/components/admin/Input.svelte";
|
||||||
|
import { getSettings, updateSettings } from "$lib/api";
|
||||||
|
import type { SiteSettings, SocialLink } from "$lib/admin-types";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import IconGithub from "~icons/simple-icons/github";
|
||||||
|
import IconLinkedin from "~icons/simple-icons/linkedin";
|
||||||
|
import IconDiscord from "~icons/simple-icons/discord";
|
||||||
|
import IconMail from "~icons/material-symbols/mail-rounded";
|
||||||
|
import IconKey from "~icons/material-symbols/vpn-key";
|
||||||
|
|
||||||
|
type Tab = "identity" | "social" | "admin";
|
||||||
|
|
||||||
|
let settings = $state<SiteSettings | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let activeTab = $state<Tab>("identity");
|
||||||
|
|
||||||
|
// Form state - will be populated when settings load
|
||||||
|
let formData = $state<SiteSettings | null>(null);
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const data = await getSettings();
|
||||||
|
settings = data;
|
||||||
|
formData = structuredClone(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load settings:", error);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const updated = await updateSettings(formData);
|
||||||
|
settings = updated;
|
||||||
|
formData = structuredClone(updated);
|
||||||
|
alert("Settings saved successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save settings:", error);
|
||||||
|
alert("Failed to save settings");
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
if (settings) {
|
||||||
|
formData = structuredClone(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSocialIcon(platform: SocialLink["platform"]) {
|
||||||
|
switch (platform) {
|
||||||
|
case "github":
|
||||||
|
return IconGithub;
|
||||||
|
case "linkedin":
|
||||||
|
return IconLinkedin;
|
||||||
|
case "discord":
|
||||||
|
return IconDiscord;
|
||||||
|
case "email":
|
||||||
|
return IconMail;
|
||||||
|
case "pgp":
|
||||||
|
return IconKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSocialPlaceholder(platform: SocialLink["platform"]) {
|
||||||
|
switch (platform) {
|
||||||
|
case "github":
|
||||||
|
return "https://github.com/username";
|
||||||
|
case "linkedin":
|
||||||
|
return "https://linkedin.com/in/username";
|
||||||
|
case "discord":
|
||||||
|
return "username";
|
||||||
|
case "email":
|
||||||
|
return "your.email@example.com";
|
||||||
|
case "pgp":
|
||||||
|
return "https://example.com/pgp-key.asc";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Settings | Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-zinc-50">Settings</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
Configure your site identity, social links, and admin preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12 text-zinc-500">Loading settings...</div>
|
||||||
|
{:else if formData}
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-zinc-800">
|
||||||
|
<nav class="flex gap-6" aria-label="Settings tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cn(
|
||||||
|
"pb-3 px-1 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
activeTab === "identity"
|
||||||
|
? "border-indigo-500 text-zinc-50"
|
||||||
|
: "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700"
|
||||||
|
)}
|
||||||
|
onclick={() => (activeTab = "identity")}
|
||||||
|
>
|
||||||
|
Identity
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cn(
|
||||||
|
"pb-3 px-1 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
activeTab === "social"
|
||||||
|
? "border-indigo-500 text-zinc-50"
|
||||||
|
: "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700"
|
||||||
|
)}
|
||||||
|
onclick={() => (activeTab = "social")}
|
||||||
|
>
|
||||||
|
Social Links
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cn(
|
||||||
|
"pb-3 px-1 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
activeTab === "admin"
|
||||||
|
? "border-indigo-500 text-zinc-50"
|
||||||
|
: "border-transparent text-zinc-400 hover:text-zinc-300 hover:border-zinc-700"
|
||||||
|
)}
|
||||||
|
onclick={() => (activeTab = "admin")}
|
||||||
|
>
|
||||||
|
Admin Preferences
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-sm shadow-black/20">
|
||||||
|
{#if activeTab === "identity"}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-base font-medium text-zinc-200 mb-4">Site Identity</h3>
|
||||||
|
<Input
|
||||||
|
label="Display Name"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.identity.displayName}
|
||||||
|
placeholder="Ryan Walters"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Occupation/Title"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.identity.occupation}
|
||||||
|
placeholder="Full-Stack Software Engineer"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Bio/Description"
|
||||||
|
type="textarea"
|
||||||
|
bind:value={formData.identity.bio}
|
||||||
|
placeholder="A brief description about yourself..."
|
||||||
|
rows={6}
|
||||||
|
help="Supports Markdown (rendered on the index page)"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Site Title"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.identity.siteTitle}
|
||||||
|
placeholder="Xevion.dev"
|
||||||
|
required
|
||||||
|
help="Displayed in browser tab and meta tags"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if activeTab === "social"}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-base font-medium text-zinc-200 mb-4">Social Links</h3>
|
||||||
|
<p class="text-sm text-zinc-500 mb-4">
|
||||||
|
Configure your social media presence on the index page
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each formData.socialLinks as link}
|
||||||
|
{@const Icon = getSocialIcon(link.platform)}
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="mt-2">
|
||||||
|
<Icon class="w-5 h-5 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-zinc-200">{link.label}</span>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<span class="text-xs text-zinc-500">Visible</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={link.visible}
|
||||||
|
class="w-4 h-4 rounded border-zinc-700 bg-zinc-800 text-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type={link.platform === "email" ? "email" : "text"}
|
||||||
|
bind:value={link.value}
|
||||||
|
placeholder={getSocialPlaceholder(link.platform)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if activeTab === "admin"}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-base font-medium text-zinc-200 mb-4">Admin Preferences</h3>
|
||||||
|
<Input
|
||||||
|
label="Session Timeout"
|
||||||
|
type="number"
|
||||||
|
bind:value={formData.adminPreferences.sessionTimeoutMinutes}
|
||||||
|
placeholder="60"
|
||||||
|
help="Minutes of inactivity before automatic logout (5-1440)"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Event Log Retention"
|
||||||
|
type="number"
|
||||||
|
bind:value={formData.adminPreferences.eventsRetentionDays}
|
||||||
|
placeholder="30"
|
||||||
|
help="Number of days to retain event logs (1-365)"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Dashboard Default Tab"
|
||||||
|
type="select"
|
||||||
|
bind:value={formData.adminPreferences.dashboardDefaultTab}
|
||||||
|
options={[
|
||||||
|
{ label: "Overview", value: "overview" },
|
||||||
|
{ label: "Events", value: "events" },
|
||||||
|
]}
|
||||||
|
help="Which tab to show by default when visiting the dashboard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onclick={handleCancel} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onclick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "$lib/components/admin/Button.svelte";
|
||||||
|
import Input from "$lib/components/admin/Input.svelte";
|
||||||
|
import Table from "$lib/components/admin/Table.svelte";
|
||||||
|
import Modal from "$lib/components/admin/Modal.svelte";
|
||||||
|
import { getAdminTags, createAdminTag, updateAdminTag, deleteAdminTag } from "$lib/api";
|
||||||
|
import type { AdminTagWithCount, CreateTagData, UpdateTagData } from "$lib/admin-types";
|
||||||
|
import IconPlus from "~icons/lucide/plus";
|
||||||
|
import IconX from "~icons/lucide/x";
|
||||||
|
|
||||||
|
let tags = $state<AdminTagWithCount[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
let showCreateForm = $state(false);
|
||||||
|
let createName = $state("");
|
||||||
|
let createSlug = $state("");
|
||||||
|
let creating = $state(false);
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
let editName = $state("");
|
||||||
|
let editSlug = $state("");
|
||||||
|
let updating = $state(false);
|
||||||
|
|
||||||
|
// Delete state
|
||||||
|
let deleteModalOpen = $state(false);
|
||||||
|
let deleteTarget = $state<AdminTagWithCount | null>(null);
|
||||||
|
let deleteConfirmReady = $state(false);
|
||||||
|
let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
try {
|
||||||
|
tags = await getAdminTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tags:", error);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadTags();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createName.trim()) return;
|
||||||
|
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
const data: CreateTagData = {
|
||||||
|
name: createName,
|
||||||
|
slug: createSlug || undefined,
|
||||||
|
};
|
||||||
|
await createAdminTag(data);
|
||||||
|
await loadTags();
|
||||||
|
createName = "";
|
||||||
|
createSlug = "";
|
||||||
|
showCreateForm = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create tag:", error);
|
||||||
|
alert("Failed to create tag");
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(tag: AdminTagWithCount) {
|
||||||
|
editingId = tag.id;
|
||||||
|
editName = tag.name;
|
||||||
|
editSlug = tag.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editName = "";
|
||||||
|
editSlug = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate() {
|
||||||
|
if (!editingId || !editName.trim()) return;
|
||||||
|
|
||||||
|
updating = true;
|
||||||
|
try {
|
||||||
|
const data: UpdateTagData = {
|
||||||
|
id: editingId,
|
||||||
|
name: editName,
|
||||||
|
slug: editSlug || undefined,
|
||||||
|
};
|
||||||
|
await updateAdminTag(data);
|
||||||
|
await loadTags();
|
||||||
|
cancelEdit();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update tag:", error);
|
||||||
|
alert("Failed to update tag");
|
||||||
|
} finally {
|
||||||
|
updating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initiateDelete(tag: AdminTagWithCount) {
|
||||||
|
deleteTarget = tag;
|
||||||
|
deleteConfirmReady = false;
|
||||||
|
|
||||||
|
// Enable confirm button after delay
|
||||||
|
deleteTimeout = setTimeout(() => {
|
||||||
|
deleteConfirmReady = true;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
deleteModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
if (deleteTimeout) {
|
||||||
|
clearTimeout(deleteTimeout);
|
||||||
|
}
|
||||||
|
deleteModalOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
deleteConfirmReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget || !deleteConfirmReady) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAdminTag(deleteTarget.id);
|
||||||
|
await loadTags();
|
||||||
|
deleteModalOpen = false;
|
||||||
|
deleteTarget = null;
|
||||||
|
deleteConfirmReady = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete tag:", error);
|
||||||
|
alert("Failed to delete tag");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Tags | Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold text-zinc-50">Tags</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
Manage project tags and categories
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onclick={() => (showCreateForm = !showCreateForm)}>
|
||||||
|
{#if showCreateForm}
|
||||||
|
<IconX class="w-4 h-4 mr-2" />
|
||||||
|
{:else}
|
||||||
|
<IconPlus class="w-4 h-4 mr-2" />
|
||||||
|
{/if}
|
||||||
|
{showCreateForm ? "Cancel" : "New Tag"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Form -->
|
||||||
|
{#if showCreateForm}
|
||||||
|
<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>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
type="text"
|
||||||
|
bind:value={createName}
|
||||||
|
placeholder="TypeScript"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Slug"
|
||||||
|
type="text"
|
||||||
|
bind:value={createSlug}
|
||||||
|
placeholder="Leave empty to auto-generate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onclick={() => (showCreateForm = false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onclick={handleCreate}
|
||||||
|
disabled={creating || !createName.trim()}
|
||||||
|
>
|
||||||
|
{creating ? "Creating..." : "Create Tag"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tags Table -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12 text-zinc-500">
|
||||||
|
Loading tags...
|
||||||
|
</div>
|
||||||
|
{:else if tags.length === 0}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<p class="text-zinc-500 mb-4">No tags yet</p>
|
||||||
|
<Button variant="primary" onclick={() => (showCreateForm = true)}>
|
||||||
|
Create your first tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Table>
|
||||||
|
<thead class="bg-zinc-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
|
||||||
|
Slug
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500">
|
||||||
|
Projects
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-zinc-500">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-800/50">
|
||||||
|
{#each tags as tag}
|
||||||
|
<tr class="hover:bg-zinc-800/30 transition-colors">
|
||||||
|
{#if editingId === tag.id}
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
bind:value={editName}
|
||||||
|
placeholder="Tag name"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
bind:value={editSlug}
|
||||||
|
placeholder="tag-slug"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-admin-text">
|
||||||
|
{tag.projectCount}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
disabled={updating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onclick={handleUpdate}
|
||||||
|
disabled={updating || !editName.trim()}
|
||||||
|
>
|
||||||
|
{updating ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{:else}
|
||||||
|
<!-- View mode -->
|
||||||
|
<td class="px-4 py-3 font-medium text-zinc-200">
|
||||||
|
{tag.name}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-500">
|
||||||
|
{tag.slug}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-300">
|
||||||
|
{tag.projectCount}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => startEdit(tag)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => initiateDelete(tag)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<Modal
|
||||||
|
bind:open={deleteModalOpen}
|
||||||
|
title="Delete Tag"
|
||||||
|
description="Are you sure you want to delete this tag? This will remove it from all projects."
|
||||||
|
confirmText={deleteConfirmReady ? "Delete" : "Wait 2s..."}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={confirmDelete}
|
||||||
|
oncancel={cancelDelete}
|
||||||
|
>
|
||||||
|
{#if deleteTarget}
|
||||||
|
<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="text-sm text-zinc-500">
|
||||||
|
Used in {deleteTarget.projectCount} project{deleteTarget.projectCount === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
import { apiFetch } from "$lib/api";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { getLogger } from "@logtape/logtape";
|
||||||
|
|
||||||
const logger = getLogger(["ssr", "routes", "internal", "health"]);
|
const logger = getLogger(["ssr", "routes", "internal", "health"]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
import type { OGImageSpec } from "$lib/og-types";
|
import type { OGImageSpec } from "$lib/og-types";
|
||||||
import { loadOGFonts } from "$lib/og-fonts";
|
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 type { Project } from "../../../projects/+page.server";
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { getLogger } from "@logtape/logtape";
|
||||||
import satori from "satori";
|
import satori from "satori";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { getOGImageUrl } from "$lib/og-types";
|
import { getOGImageUrl } from "$lib/og-types";
|
||||||
|
|
||||||
interface ProjectLink {
|
interface ProjectLink {
|
||||||
@@ -9,6 +9,7 @@ interface ProjectLink {
|
|||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user