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