mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
feat: add admin button to main layout, improve projects UI with click navigation, native scrollbar styles, better timestamp format
- Add persistent admin dashboard button in header when authenticated - Make project list rows clickable for navigation to edit page - Move delete action to edit page with confirmation modal - Add sessionStorage persistence for auth state across page loads - Improve timestamp formatting with relative times - Add native scrollbar styling for light/dark themes
This commit is contained in:
@@ -120,6 +120,64 @@ html.dark {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native scrollbars (Webkit: Chrome, Safari, Edge) */
|
||||||
|
html:not(.dark) ::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) ::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) ::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) ::-webkit-scrollbar-thumb:active {
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-thumb:active {
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Native scrollbars (Firefox) */
|
||||||
|
html:not(.dark) {
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.25) rgba(0, 0, 0, 0.05);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.35) rgba(255, 255, 255, 0.05);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
/* Utility class for page main wrapper */
|
/* Utility class for page main wrapper */
|
||||||
.page-main {
|
.page-main {
|
||||||
@apply relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300;
|
@apply relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import IconLayoutDashboard from "~icons/lucide/layout-dashboard";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if authStore.isAuthenticated}
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
aria-label="Admin dashboard"
|
||||||
|
class="relative size-9 rounded-md border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/50 hover:bg-zinc-200 dark:hover:bg-zinc-800/70 transition-all duration-200 cursor-pointer flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<IconLayoutDashboard class="size-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
project?: AdminProject | null;
|
project?: AdminProject | null;
|
||||||
availableTags: TagWithIcon[];
|
availableTags: TagWithIcon[];
|
||||||
onsubmit: (data: CreateProjectData) => Promise<void>;
|
onsubmit: (data: CreateProjectData) => Promise<void>;
|
||||||
|
ondelete?: () => void;
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
project = null,
|
project = null,
|
||||||
availableTags,
|
availableTags,
|
||||||
onsubmit,
|
onsubmit,
|
||||||
|
ondelete,
|
||||||
submitLabel = "Save Project",
|
submitLabel = "Save Project",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -182,10 +184,17 @@
|
|||||||
<MediaManager projectId={project?.id ?? null} media={project?.media ?? []} />
|
<MediaManager projectId={project?.id ?? null} media={project?.media ?? []} />
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border">
|
<div class="flex justify-between gap-3 pt-4 border-t border-admin-border">
|
||||||
<Button variant="secondary" href="/admin/projects">Cancel</Button>
|
{#if ondelete}
|
||||||
<Button type="submit" variant="primary" disabled={submitting || !name}>
|
<Button variant="danger" onclick={ondelete}>Delete</Button>
|
||||||
{submitting ? "Saving..." : submitLabel}
|
{:else}
|
||||||
</Button>
|
<div></div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<Button variant="secondary" href="/admin/projects">Cancel</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={submitting || !name}>
|
||||||
|
{submitting ? "Saving..." : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { telemetry } from "$lib/telemetry";
|
import { telemetry } from "$lib/telemetry";
|
||||||
|
|
||||||
class AuthStore {
|
class AuthStore {
|
||||||
|
private static STORAGE_KEY = "admin_session_active";
|
||||||
|
|
||||||
isAuthenticated = $state(false);
|
isAuthenticated = $state(false);
|
||||||
username = $state<string | null>(null);
|
username = $state<string | null>(null);
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const stored = sessionStorage.getItem(AuthStore.STORAGE_KEY);
|
||||||
|
if (stored === "true") {
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async login(username: string, password: string): Promise<boolean> {
|
async login(username: string, password: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/login", {
|
const response = await fetch("/api/login", {
|
||||||
@@ -19,6 +30,7 @@ class AuthStore {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
this.username = data.username;
|
this.username = data.username;
|
||||||
|
sessionStorage.setItem(AuthStore.STORAGE_KEY, "true");
|
||||||
telemetry.identifyAdmin(data.username);
|
telemetry.identifyAdmin(data.username);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -41,6 +53,7 @@ class AuthStore {
|
|||||||
} finally {
|
} finally {
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
this.username = null;
|
this.username = null;
|
||||||
|
sessionStorage.removeItem(AuthStore.STORAGE_KEY);
|
||||||
telemetry.reset();
|
telemetry.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +82,7 @@ class AuthStore {
|
|||||||
setSession(username: string): void {
|
setSession(username: string): void {
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
|
sessionStorage.setItem(AuthStore.STORAGE_KEY, "true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
import { OverlayScrollbars } from "overlayscrollbars";
|
import { OverlayScrollbars } from "overlayscrollbars";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { themeStore } from "$lib/stores/theme.svelte";
|
import { themeStore } from "$lib/stores/theme.svelte";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { afterNavigate, onNavigate } from "$app/navigation";
|
import { afterNavigate, onNavigate } from "$app/navigation";
|
||||||
import { telemetry } from "$lib/telemetry";
|
import { telemetry } from "$lib/telemetry";
|
||||||
import Clouds from "$lib/components/Clouds.svelte";
|
import Clouds from "$lib/components/Clouds.svelte";
|
||||||
import Dots from "$lib/components/Dots.svelte";
|
import Dots from "$lib/components/Dots.svelte";
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
import AdminButton from "$lib/components/AdminButton.svelte";
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
@@ -84,6 +86,9 @@
|
|||||||
// Initialize theme store
|
// Initialize theme store
|
||||||
themeStore.init();
|
themeStore.init();
|
||||||
|
|
||||||
|
// Initialize auth store (checks sessionStorage for admin session)
|
||||||
|
authStore.init();
|
||||||
|
|
||||||
// Initialize PostHog telemetry (page views tracked via afterNavigate)
|
// Initialize PostHog telemetry (page views tracked via afterNavigate)
|
||||||
telemetry.init();
|
telemetry.init();
|
||||||
|
|
||||||
@@ -137,11 +142,12 @@
|
|||||||
<Dots style="view-transition-name: background" />
|
<Dots style="view-transition-name: background" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Theme toggle - persistent across page transitions -->
|
<!-- Header buttons - persistent across page transitions -->
|
||||||
<div
|
<div
|
||||||
class="fixed top-5 right-6 z-50"
|
class="fixed top-5 right-6 z-50 flex items-center gap-2"
|
||||||
style="view-transition-name: theme-toggle"
|
style="view-transition-name: theme-toggle"
|
||||||
>
|
>
|
||||||
|
<AdminButton />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,36 +1,17 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
import { apiFetch } from "$lib/api.server";
|
import { apiFetch } from "$lib/api.server";
|
||||||
import { renderIconsBatch } from "$lib/server/icons";
|
import { renderIconsBatch } from "$lib/server/icons";
|
||||||
import type {
|
import type { AdminProject, TagWithIcon } from "$lib/admin-types";
|
||||||
AdminProject,
|
|
||||||
ProjectStatus,
|
|
||||||
TagWithIcon,
|
|
||||||
} from "$lib/admin-types";
|
|
||||||
|
|
||||||
export interface ProjectWithTagIcons extends Omit<AdminProject, "tags"> {
|
export interface ProjectWithTagIcons extends Omit<AdminProject, "tags"> {
|
||||||
tags: TagWithIcon[];
|
tags: TagWithIcon[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status display configuration (icons for server-side rendering)
|
|
||||||
const STATUS_ICONS: Record<ProjectStatus, string> = {
|
|
||||||
active: "lucide:circle-check",
|
|
||||||
maintained: "lucide:wrench",
|
|
||||||
archived: "lucide:archive",
|
|
||||||
hidden: "lucide:eye-off",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
||||||
|
|
||||||
// Collect all icon identifiers for batch rendering
|
// Collect all tag icon identifiers for batch rendering
|
||||||
const iconIds = new Set<string>();
|
const iconIds = new Set<string>();
|
||||||
|
|
||||||
// Add status icons
|
|
||||||
for (const icon of Object.values(STATUS_ICONS)) {
|
|
||||||
iconIds.add(icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tag icons
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
for (const tag of project.tags) {
|
for (const tag of project.tags) {
|
||||||
if (tag.icon) {
|
if (tag.icon) {
|
||||||
@@ -42,14 +23,6 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
|||||||
// Batch render all icons
|
// Batch render all icons
|
||||||
const icons = await renderIconsBatch([...iconIds], { size: 12 });
|
const icons = await renderIconsBatch([...iconIds], { size: 12 });
|
||||||
|
|
||||||
// Build status icons map
|
|
||||||
const statusIcons: Record<ProjectStatus, string> = {
|
|
||||||
active: icons.get(STATUS_ICONS.active) ?? "",
|
|
||||||
maintained: icons.get(STATUS_ICONS.maintained) ?? "",
|
|
||||||
archived: icons.get(STATUS_ICONS.archived) ?? "",
|
|
||||||
hidden: icons.get(STATUS_ICONS.hidden) ?? "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map icons back to project tags
|
// Map icons back to project tags
|
||||||
const projectsWithIcons: ProjectWithTagIcons[] = projects.map((project) => ({
|
const projectsWithIcons: ProjectWithTagIcons[] = projects.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
@@ -61,6 +34,5 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
projects: projectsWithIcons,
|
projects: projectsWithIcons,
|
||||||
statusIcons,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from "$lib/components/admin/Button.svelte";
|
import Button from "$lib/components/admin/Button.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 TagChip from "$lib/components/TagChip.svelte";
|
import TagChip from "$lib/components/TagChip.svelte";
|
||||||
import { deleteAdminProject } from "$lib/api";
|
import { goto } from "$app/navigation";
|
||||||
import { invalidateAll } from "$app/navigation";
|
|
||||||
import type { ProjectWithTagIcons } from "./+page.server";
|
import type { ProjectWithTagIcons } from "./+page.server";
|
||||||
import type { ProjectStatus } from "$lib/admin-types";
|
import type { ProjectStatus } from "$lib/admin-types";
|
||||||
import IconPlus from "~icons/lucide/plus";
|
import IconPlus from "~icons/lucide/plus";
|
||||||
import { getLogger } from "@logtape/logtape";
|
|
||||||
|
|
||||||
const logger = getLogger(["admin", "projects"]);
|
|
||||||
|
|
||||||
// Status display configuration (colors match Badge component)
|
// Status display configuration (colors match Badge component)
|
||||||
const STATUS_CONFIG: Record<ProjectStatus, { color: string; label: string }> =
|
const STATUS_CONFIG: Record<ProjectStatus, { color: string; label: string }> =
|
||||||
@@ -24,62 +19,48 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
projects: ProjectWithTagIcons[];
|
projects: ProjectWithTagIcons[];
|
||||||
statusIcons: Record<ProjectStatus, string>;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
let deleteModalOpen = $state(false);
|
|
||||||
let deleteTarget = $state<ProjectWithTagIcons | null>(null);
|
|
||||||
let deleteConfirmReady = $state(false);
|
|
||||||
let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function initiateDelete(project: ProjectWithTagIcons) {
|
|
||||||
deleteTarget = project;
|
|
||||||
deleteConfirmReady = false;
|
|
||||||
|
|
||||||
// Enable confirm button after delay
|
|
||||||
deleteTimeout = setTimeout(() => {
|
|
||||||
deleteConfirmReady = true;
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
deleteModalOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDelete() {
|
|
||||||
if (deleteTimeout) {
|
|
||||||
clearTimeout(deleteTimeout);
|
|
||||||
}
|
|
||||||
deleteModalOpen = false;
|
|
||||||
deleteTarget = null;
|
|
||||||
deleteConfirmReady = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDelete() {
|
|
||||||
if (!deleteTarget || !deleteConfirmReady) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteAdminProject(deleteTarget.id);
|
|
||||||
await invalidateAll();
|
|
||||||
deleteModalOpen = false;
|
|
||||||
deleteTarget = null;
|
|
||||||
deleteConfirmReady = false;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to delete project", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
alert("Failed to delete project");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleDateString("en-US", {
|
const now = new Date();
|
||||||
year: "numeric",
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
const timeStr = date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recent: relative timestamps
|
||||||
|
if (diffMins < 1) return "just now";
|
||||||
|
if (diffMins === 1) return "1 minute ago";
|
||||||
|
if (diffMins < 60) return `${diffMins} minutes ago`;
|
||||||
|
if (diffHours === 1) return "1 hour ago";
|
||||||
|
if (diffHours < 24) return `${diffHours} hours ago`;
|
||||||
|
|
||||||
|
// Yesterday: relative with time
|
||||||
|
if (diffDays === 1 || (diffHours >= 24 && diffHours < 48)) {
|
||||||
|
return `Yesterday at ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older: absolute timestamp with time
|
||||||
|
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
};
|
||||||
|
// Only show year if different from current year
|
||||||
|
if (date.getFullYear() !== now.getFullYear()) {
|
||||||
|
dateOptions.year = "numeric";
|
||||||
|
}
|
||||||
|
const datePartStr = date.toLocaleDateString("en-US", dateOptions);
|
||||||
|
return `${datePartStr} at ${timeStr}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -134,16 +115,19 @@
|
|||||||
>
|
>
|
||||||
Last Activity
|
Last Activity
|
||||||
</th>
|
</th>
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-right text-xs font-medium text-admin-text-muted"
|
|
||||||
>
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-admin-border">
|
<tbody class="divide-y divide-admin-border">
|
||||||
{#each data.projects as project (project.id)}
|
{#each data.projects as project (project.id)}
|
||||||
<tr class="hover:bg-admin-surface-hover/50 transition-colors">
|
<tr
|
||||||
|
class="hover:bg-admin-surface-hover/50 transition-colors cursor-pointer"
|
||||||
|
onclick={() => goto(`/admin/projects/${project.id}`)}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
(e.key === "Enter" || e.key === " ") &&
|
||||||
|
goto(`/admin/projects/${project.id}`)}
|
||||||
|
role="link"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
<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">
|
||||||
<div>
|
<div>
|
||||||
@@ -160,11 +144,15 @@
|
|||||||
<TagChip
|
<TagChip
|
||||||
name={STATUS_CONFIG[project.status].label}
|
name={STATUS_CONFIG[project.status].label}
|
||||||
color={STATUS_CONFIG[project.status].color}
|
color={STATUS_CONFIG[project.status].color}
|
||||||
iconSvg={data.statusIcons[project.status]}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-wrap gap-1">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{#each project.tags.slice(0, 3) as tag (tag.id)}
|
{#each project.tags.slice(0, 3) as tag (tag.id)}
|
||||||
<TagChip
|
<TagChip
|
||||||
name={tag.name}
|
name={tag.name}
|
||||||
@@ -185,47 +173,9 @@
|
|||||||
<td class="px-4 py-3 text-admin-text-secondary text-sm">
|
<td class="px-4 py-3 text-admin-text-secondary text-sm">
|
||||||
{formatDate(project.lastActivity)}
|
{formatDate(project.lastActivity)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
href={`/admin/projects/${project.id}`}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => initiateDelete(project)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<Modal
|
|
||||||
bind:open={deleteModalOpen}
|
|
||||||
title="Delete Project"
|
|
||||||
description="Are you sure you want to delete this project? This action cannot be undone."
|
|
||||||
confirmText={deleteConfirmReady ? "Delete" : `Wait ${2}s...`}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={confirmDelete}
|
|
||||||
oncancel={cancelDelete}
|
|
||||||
>
|
|
||||||
{#if deleteTarget}
|
|
||||||
<div
|
|
||||||
class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3"
|
|
||||||
>
|
|
||||||
<p class="font-medium text-admin-text">{deleteTarget.name}</p>
|
|
||||||
<p class="text-sm text-admin-text-secondary">{deleteTarget.slug}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
|
||||||
import { updateAdminProject } from "$lib/api";
|
import Modal from "$lib/components/admin/Modal.svelte";
|
||||||
|
import { updateAdminProject, deleteAdminProject } from "$lib/api";
|
||||||
import type {
|
import type {
|
||||||
UpdateProjectData,
|
UpdateProjectData,
|
||||||
CreateProjectData,
|
CreateProjectData,
|
||||||
TagWithIcon,
|
TagWithIcon,
|
||||||
} from "$lib/admin-types";
|
} from "$lib/admin-types";
|
||||||
|
import { getLogger } from "@logtape/logtape";
|
||||||
|
|
||||||
|
const logger = getLogger(["admin", "projects", "edit"]);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
@@ -18,6 +22,38 @@
|
|||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
// Delete modal state
|
||||||
|
let deleteModalOpen = $state(false);
|
||||||
|
let deleteConfirmReady = $state(false);
|
||||||
|
let deleteTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function initiateDelete() {
|
||||||
|
deleteConfirmReady = false;
|
||||||
|
deleteTimeout = setTimeout(() => {
|
||||||
|
deleteConfirmReady = true;
|
||||||
|
}, 2000);
|
||||||
|
deleteModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
if (deleteTimeout) clearTimeout(deleteTimeout);
|
||||||
|
deleteModalOpen = false;
|
||||||
|
deleteConfirmReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!data.project || !deleteConfirmReady) return;
|
||||||
|
try {
|
||||||
|
await deleteAdminProject(data.project.id);
|
||||||
|
goto(resolve("/admin/projects"));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to delete project", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
alert("Failed to delete project");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(formData: CreateProjectData) {
|
async function handleSubmit(formData: CreateProjectData) {
|
||||||
if (!data.project) return;
|
if (!data.project) return;
|
||||||
|
|
||||||
@@ -60,8 +96,29 @@
|
|||||||
project={data.project}
|
project={data.project}
|
||||||
availableTags={data.availableTags}
|
availableTags={data.availableTags}
|
||||||
onsubmit={handleSubmit}
|
onsubmit={handleSubmit}
|
||||||
|
ondelete={initiateDelete}
|
||||||
submitLabel="Update Project"
|
submitLabel="Update Project"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<Modal
|
||||||
|
bind:open={deleteModalOpen}
|
||||||
|
title="Delete Project"
|
||||||
|
description="Are you sure you want to delete this project? This action cannot be undone."
|
||||||
|
confirmText={deleteConfirmReady ? "Delete" : "Wait 2s..."}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={confirmDelete}
|
||||||
|
oncancel={cancelDelete}
|
||||||
|
>
|
||||||
|
{#if data.project}
|
||||||
|
<div
|
||||||
|
class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3"
|
||||||
|
>
|
||||||
|
<p class="font-medium text-admin-text">{data.project.name}</p>
|
||||||
|
<p class="text-sm text-admin-text-secondary">{data.project.slug}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user