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:
2026-01-06 10:07:30 -06:00
parent 045781f7a5
commit 16bf2b76f3
32 changed files with 3260 additions and 60 deletions
+108
View File
@@ -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;
}
+64
View File
@@ -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;
}
}
+752 -53
View File
@@ -1,64 +1,763 @@
import { getLogger } from "@logtape/logtape";
import { env } from "$env/dynamic/private";
import type {
AdminProject,
AdminTag,
AdminTagWithCount,
AdminEvent,
AdminStats,
CreateProjectData,
UpdateProjectData,
CreateTagData,
UpdateTagData,
SiteSettings,
SiteIdentity,
SocialLink,
AdminPreferences,
} from "./admin-types";
const logger = getLogger(["ssr", "lib", "api"]);
// ============================================================================
// ADMIN API FUNCTIONS (Mocked for now, will be replaced with real API calls)
// ============================================================================
const upstreamUrl = env.UPSTREAM_URL;
const isUnixSocket =
upstreamUrl?.startsWith("/") || upstreamUrl?.startsWith("./");
const baseUrl = isUnixSocket ? "http://localhost" : upstreamUrl;
// Mock data storage (in-memory for now)
let MOCK_TAGS: AdminTag[] = [
{ id: "tag-1", slug: "rust", name: "Rust", createdAt: "2024-01-15T10:00:00Z" },
{ id: "tag-2", slug: "typescript", name: "TypeScript", createdAt: "2024-01-16T10:00:00Z" },
{ id: "tag-3", slug: "web", name: "Web", createdAt: "2024-01-17T10:00:00Z" },
{ id: "tag-4", slug: "cli", name: "CLI", createdAt: "2024-01-18T10:00:00Z" },
{ id: "tag-5", slug: "api", name: "API", createdAt: "2024-01-19T10:00:00Z" },
{ id: "tag-6", slug: "database", name: "Database", createdAt: "2024-01-20T10:00:00Z" },
{ id: "tag-7", slug: "svelte", name: "Svelte", createdAt: "2024-01-21T10:00:00Z" },
{ id: "tag-8", slug: "python", name: "Python", createdAt: "2024-01-22T10:00:00Z" },
{ id: "tag-9", slug: "machine-learning", name: "Machine Learning", createdAt: "2024-01-23T10:00:00Z" },
{ id: "tag-10", slug: "docker", name: "Docker", createdAt: "2024-01-24T10:00:00Z" },
{ id: "tag-11", slug: "kubernetes", name: "Kubernetes", createdAt: "2024-01-25T10:00:00Z" },
{ id: "tag-12", slug: "react", name: "React", createdAt: "2024-01-26T10:00:00Z" },
{ id: "tag-13", slug: "nextjs", name: "Next.js", createdAt: "2024-01-27T10:00:00Z" },
{ id: "tag-14", slug: "tailwind", name: "Tailwind CSS", createdAt: "2024-01-28T10:00:00Z" },
{ id: "tag-15", slug: "graphql", name: "GraphQL", createdAt: "2024-01-29T10:00:00Z" },
{ id: "tag-16", slug: "postgres", name: "PostgreSQL", createdAt: "2024-01-30T10:00:00Z" },
{ id: "tag-17", slug: "redis", name: "Redis", createdAt: "2024-01-31T10:00:00Z" },
{ id: "tag-18", slug: "aws", name: "AWS", createdAt: "2024-02-01T10:00:00Z" },
{ id: "tag-19", slug: "devops", name: "DevOps", createdAt: "2024-02-02T10:00:00Z" },
{ id: "tag-20", slug: "security", name: "Security", createdAt: "2024-02-03T10:00:00Z" },
];
export async function apiFetch<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");
}
let MOCK_PROJECTS: AdminProject[] = [
{
id: "proj-1",
slug: "portfolio-site",
title: "Portfolio Site",
description: "Personal portfolio with project showcase and blog",
status: "active",
githubRepo: "xevion/xevion.dev",
demoUrl: "https://xevion.dev",
priority: 100,
icon: "fa-globe",
lastGithubActivity: "2024-12-20T15:30:00Z",
createdAt: "2024-01-10T08:00:00Z",
updatedAt: "2024-12-20T15:30:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[1], MOCK_TAGS[6], MOCK_TAGS[13]],
},
{
id: "proj-2",
slug: "task-tracker",
title: "Task Tracker CLI",
description: "Command-line task management tool with SQLite backend",
status: "maintained",
githubRepo: "xevion/task-tracker",
demoUrl: null,
priority: 90,
icon: "fa-check-square",
lastGithubActivity: "2024-11-15T10:20:00Z",
createdAt: "2024-02-05T12:00:00Z",
updatedAt: "2024-11-15T10:20:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[3], MOCK_TAGS[5]],
},
{
id: "proj-3",
slug: "api-gateway",
title: "API Gateway Service",
description: "High-performance API gateway with rate limiting and caching",
status: "active",
githubRepo: "xevion/api-gateway",
demoUrl: null,
priority: 85,
icon: "fa-server",
lastGithubActivity: "2025-01-05T14:45:00Z",
createdAt: "2024-03-12T09:30:00Z",
updatedAt: "2025-01-05T14:45:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[4], MOCK_TAGS[16], MOCK_TAGS[19]],
},
{
id: "proj-4",
slug: "data-pipeline",
title: "Data Pipeline Framework",
description: "ETL framework for processing large datasets",
status: "archived",
githubRepo: "xevion/data-pipeline",
demoUrl: null,
priority: 50,
icon: "fa-database",
lastGithubActivity: "2024-06-10T08:15:00Z",
createdAt: "2024-01-20T11:00:00Z",
updatedAt: "2024-06-10T08:15:00Z",
tags: [MOCK_TAGS[7], MOCK_TAGS[5], MOCK_TAGS[15]],
},
{
id: "proj-5",
slug: "ml-classifier",
title: "ML Image Classifier",
description: "Deep learning model for image classification",
status: "active",
githubRepo: "xevion/ml-classifier",
demoUrl: "https://ml-demo.xevion.dev",
priority: 80,
icon: "fa-brain",
lastGithubActivity: "2024-12-28T16:00:00Z",
createdAt: "2024-04-01T13:00:00Z",
updatedAt: "2024-12-28T16:00:00Z",
tags: [MOCK_TAGS[7], MOCK_TAGS[8], MOCK_TAGS[9]],
},
{
id: "proj-6",
slug: "container-orchestrator",
title: "Container Orchestrator",
description: "Lightweight container orchestration for small deployments",
status: "active",
githubRepo: "xevion/orchestrator",
demoUrl: null,
priority: 75,
icon: "fa-ship",
lastGithubActivity: "2025-01-02T09:30:00Z",
createdAt: "2024-05-10T10:00:00Z",
updatedAt: "2025-01-02T09:30:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[9], MOCK_TAGS[10], MOCK_TAGS[18]],
},
{
id: "proj-7",
slug: "dashboard-components",
title: "Dashboard Component Library",
description: "Reusable React components for building admin dashboards",
status: "maintained",
githubRepo: "xevion/dashboard-ui",
demoUrl: "https://dashboard-demo.xevion.dev",
priority: 70,
icon: "fa-th-large",
lastGithubActivity: "2024-10-20T12:00:00Z",
createdAt: "2024-02-15T14:30:00Z",
updatedAt: "2024-10-20T12:00:00Z",
tags: [MOCK_TAGS[1], MOCK_TAGS[11], MOCK_TAGS[13]],
},
{
id: "proj-8",
slug: "graphql-server",
title: "GraphQL Server Boilerplate",
description: "Production-ready GraphQL server with auth and subscriptions",
status: "active",
githubRepo: "xevion/graphql-server",
demoUrl: null,
priority: 65,
icon: "fa-project-diagram",
lastGithubActivity: "2024-12-15T11:30:00Z",
createdAt: "2024-03-20T08:00:00Z",
updatedAt: "2024-12-15T11:30:00Z",
tags: [MOCK_TAGS[1], MOCK_TAGS[4], MOCK_TAGS[14], MOCK_TAGS[15]],
},
{
id: "proj-9",
slug: "security-scanner",
title: "Security Scanner",
description: "Automated security vulnerability scanner for web applications",
status: "active",
githubRepo: "xevion/sec-scanner",
demoUrl: null,
priority: 60,
icon: "fa-shield-alt",
lastGithubActivity: "2024-12-30T10:00:00Z",
createdAt: "2024-06-01T09:00:00Z",
updatedAt: "2024-12-30T10:00:00Z",
tags: [MOCK_TAGS[7], MOCK_TAGS[2], MOCK_TAGS[19]],
},
{
id: "proj-10",
slug: "cache-optimizer",
title: "Cache Optimization Library",
description: "Smart caching layer with automatic invalidation",
status: "maintained",
githubRepo: "xevion/cache-lib",
demoUrl: null,
priority: 55,
icon: "fa-bolt",
lastGithubActivity: "2024-09-10T13:20:00Z",
createdAt: "2024-04-15T10:30:00Z",
updatedAt: "2024-09-10T13:20:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[16], MOCK_TAGS[4]],
},
{
id: "proj-11",
slug: "deployment-tools",
title: "Deployment Automation Tools",
description: "CLI tools for automated deployments to multiple cloud providers",
status: "active",
githubRepo: "xevion/deploy-tools",
demoUrl: null,
priority: 50,
icon: "fa-rocket",
lastGithubActivity: "2025-01-01T08:00:00Z",
createdAt: "2024-07-10T11:00:00Z",
updatedAt: "2025-01-01T08:00:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[3], MOCK_TAGS[18], MOCK_TAGS[18]],
},
{
id: "proj-12",
slug: "log-aggregator",
title: "Log Aggregation Service",
description: "Centralized logging with search and analytics",
status: "active",
githubRepo: "xevion/log-aggregator",
demoUrl: null,
priority: 45,
icon: "fa-file-alt",
lastGithubActivity: "2024-12-25T15:00:00Z",
createdAt: "2024-08-05T12:00:00Z",
updatedAt: "2024-12-25T15:00:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[5], MOCK_TAGS[15]],
},
{
id: "proj-13",
slug: "ui-playground",
title: "UI Component Playground",
description: "Interactive playground for testing UI components",
status: "maintained",
githubRepo: "xevion/ui-playground",
demoUrl: "https://ui.xevion.dev",
priority: 40,
icon: "fa-palette",
lastGithubActivity: "2024-08-20T10:30:00Z",
createdAt: "2024-05-20T09:00:00Z",
updatedAt: "2024-08-20T10:30:00Z",
tags: [MOCK_TAGS[1], MOCK_TAGS[11], MOCK_TAGS[13]],
},
{
id: "proj-14",
slug: "config-manager",
title: "Configuration Manager",
description: "Type-safe configuration management for microservices",
status: "archived",
githubRepo: "xevion/config-manager",
demoUrl: null,
priority: 30,
icon: "fa-cog",
lastGithubActivity: "2024-05-15T14:00:00Z",
createdAt: "2024-02-28T11:30:00Z",
updatedAt: "2024-05-15T14:00:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[1]],
},
{
id: "proj-15",
slug: "websocket-proxy",
title: "WebSocket Proxy",
description: "Scalable WebSocket proxy with load balancing",
status: "active",
githubRepo: "xevion/ws-proxy",
demoUrl: null,
priority: 35,
icon: "fa-exchange-alt",
lastGithubActivity: "2024-11-30T16:30:00Z",
createdAt: "2024-06-15T13:00:00Z",
updatedAt: "2024-11-30T16:30:00Z",
tags: [MOCK_TAGS[0], MOCK_TAGS[2], MOCK_TAGS[4]],
},
];
const url = `${baseUrl}${path}`;
const method = init?.method ?? "GET";
let MOCK_EVENTS: AdminEvent[] = [
{
id: "evt-1",
timestamp: "2025-01-06T10:30:00Z",
level: "info",
target: "project.created",
message: "Created new project: Portfolio Site",
metadata: { projectId: "proj-1", userId: "admin" },
},
{
id: "evt-2",
timestamp: "2025-01-06T09:15:00Z",
level: "info",
target: "github.sync",
message: "GitHub sync completed for 15 projects",
metadata: { projectCount: 15, duration: 2340 },
},
{
id: "evt-3",
timestamp: "2025-01-06T08:45:00Z",
level: "warning",
target: "github.sync",
message: "Rate limit approaching: 450/5000 requests remaining",
metadata: { remaining: 450, limit: 5000 },
},
{
id: "evt-4",
timestamp: "2025-01-06T08:00:00Z",
level: "error",
target: "github.sync",
message: "Failed to sync project: ml-classifier",
metadata: { projectId: "proj-5", error: "Repository not found" },
},
{
id: "evt-5",
timestamp: "2025-01-06T07:30:00Z",
level: "info",
target: "tag.created",
message: "Created new tag: Rust",
metadata: { tagId: "tag-1" },
},
{
id: "evt-6",
timestamp: "2025-01-05T23:00:00Z",
level: "info",
target: "project.updated",
message: "Updated project: API Gateway Service",
metadata: { projectId: "proj-3", changes: ["description", "tags"] },
},
{
id: "evt-7",
timestamp: "2025-01-05T22:15:00Z",
level: "info",
target: "tag.deleted",
message: "Deleted tag: Legacy",
metadata: { tagId: "tag-deleted", tagName: "Legacy" },
},
{
id: "evt-8",
timestamp: "2025-01-05T20:30:00Z",
level: "error",
target: "media.upload",
message: "Failed to upload media: file size exceeds limit",
metadata: { filename: "banner.png", size: 12582912, limit: 10485760 },
},
{
id: "evt-9",
timestamp: "2025-01-05T19:00:00Z",
level: "info",
target: "project.deleted",
message: "Deleted project: Old Website",
metadata: { projectId: "proj-old", projectName: "Old Website" },
},
{
id: "evt-10",
timestamp: "2025-01-05T18:30:00Z",
level: "warning",
target: "cache.invalidation",
message: "Cache invalidation took longer than expected",
metadata: { duration: 5420, threshold: 3000 },
},
];
const fetchOptions: RequestInit & { unix?: string } = {
...init,
signal: init?.signal ?? AbortSignal.timeout(30_000),
// Generate additional events for scrolling test
for (let i = 11; i <= 100; i++) {
const levels: AdminEvent["level"][] = ["info", "warning", "error"];
const targets = [
"project.created",
"project.updated",
"project.deleted",
"tag.created",
"tag.updated",
"tag.deleted",
"github.sync",
"cache.invalidation",
"media.upload",
];
const level = levels[Math.floor(Math.random() * levels.length)];
const target = targets[Math.floor(Math.random() * targets.length)];
const hoursAgo = i;
const date = new Date();
date.setHours(date.getHours() - hoursAgo);
MOCK_EVENTS.push({
id: `evt-${i}`,
timestamp: date.toISOString(),
level,
target,
message: `Mock event ${i}: ${target}`,
metadata: { eventNumber: i },
});
}
let MOCK_SETTINGS: SiteSettings = {
identity: {
displayName: "Ryan Walters",
occupation: "Full-Stack Software Engineer",
bio: "A fanatical software engineer with expertise and passion for sound, scalable and high-performance applications. I'm always working on something new.\nSometimes innovative — sometimes crazy.",
siteTitle: "Xevion.dev",
},
socialLinks: [
{
id: "social-1",
platform: "github",
label: "GitHub",
value: "https://github.com/Xevion",
visible: true,
},
{
id: "social-2",
platform: "linkedin",
label: "LinkedIn",
value: "https://linkedin.com/in/ryancwalters",
visible: true,
},
{
id: "social-3",
platform: "discord",
label: "Discord",
value: "xevion",
visible: true,
},
{
id: "social-4",
platform: "email",
label: "Email",
value: "your.email@example.com",
visible: false,
},
{
id: "social-5",
platform: "pgp",
label: "PGP Key",
value: "",
visible: false,
},
],
adminPreferences: {
sessionTimeoutMinutes: 60,
eventsRetentionDays: 30,
dashboardDefaultTab: "overview",
},
};
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// Admin Projects API
export async function getAdminProjects(): Promise<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,
};
if (isUnixSocket) {
fetchOptions.unix = upstreamUrl;
}
MOCK_PROJECTS.push(newProject);
logger.debug("API request", {
method,
url,
path,
isUnixSocket,
upstreamUrl,
// Add event
MOCK_EVENTS.unshift({
id: generateId(),
timestamp: now,
level: "info",
target: "project.created",
message: `Created new project: ${newProject.title}`,
metadata: { projectId: newProject.id },
});
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
logger.error("API request failed", {
method,
url,
status: response.status,
statusText: response.statusText,
});
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
logger.debug("API response", { method, url, status: response.status });
return data;
} catch (error) {
logger.error("API request exception", {
method,
url,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
return newProject;
}
export async function updateAdminProject(
data: UpdateProjectData,
): Promise<AdminProject> {
// TODO: Replace with apiFetch(`/admin/api/projects/${data.id}`, { method: 'PUT', body: JSON.stringify(data) })
await new Promise((resolve) => setTimeout(resolve, 200));
const index = MOCK_PROJECTS.findIndex((p) => p.id === data.id);
if (index === -1) throw new Error("Project not found");
const now = new Date().toISOString();
const slug = data.slug || slugify(data.title);
const tags = MOCK_TAGS.filter((t) => data.tagIds.includes(t.id));
const updatedProject: AdminProject = {
...MOCK_PROJECTS[index],
slug,
title: data.title,
description: data.description,
status: data.status,
githubRepo: data.githubRepo || null,
demoUrl: data.demoUrl || null,
priority: data.priority,
icon: data.icon || null,
updatedAt: now,
tags,
};
MOCK_PROJECTS[index] = updatedProject;
// Add event
MOCK_EVENTS.unshift({
id: generateId(),
timestamp: now,
level: "info",
target: "project.updated",
message: `Updated project: ${updatedProject.title}`,
metadata: { projectId: updatedProject.id },
});
return updatedProject;
}
export async function deleteAdminProject(id: string): Promise<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);
}
+3 -1
View File
@@ -6,15 +6,17 @@
let {
class: className = "",
backgroundClass = "",
bgColor = "bg-black",
children,
}: {
class?: string;
backgroundClass?: string;
bgColor?: string;
children?: Snippet;
} = $props();
</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]} />
<main class={cn("relative min-h-screen text-zinc-50", className)}>
{#if children}
+30
View File
@@ -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>
+103
View File
@@ -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>
+84
View File
@@ -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>
+131
View File
@@ -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}
+16
View File
@@ -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>
+90
View File
@@ -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();