mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 14:26:37 -06:00
feat: add site settings management with identity and social links
- Add site_identity and social_links database tables - Implement GET/PUT /api/settings endpoints (GET public, PUT authenticated) - Replace hardcoded homepage content with database-driven settings - Add admin settings UI with identity and social links editing
This commit is contained in:
@@ -78,19 +78,14 @@ export interface AuthSession {
|
||||
expiresAt: string; // ISO 8601
|
||||
}
|
||||
|
||||
export type SocialPlatform =
|
||||
| "github"
|
||||
| "linkedin"
|
||||
| "discord"
|
||||
| "email"
|
||||
| "pgp";
|
||||
|
||||
export interface SocialLink {
|
||||
id: string;
|
||||
platform: SocialPlatform;
|
||||
platform: string; // Not an enum for extensibility
|
||||
label: string;
|
||||
value: string; // URL, username, or email address
|
||||
icon: string; // Icon identifier (e.g., 'simple-icons:github')
|
||||
visible: boolean;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export interface SiteIdentity {
|
||||
@@ -100,14 +95,7 @@ export interface SiteIdentity {
|
||||
siteTitle: string;
|
||||
}
|
||||
|
||||
export interface AdminPreferences {
|
||||
sessionTimeoutMinutes: number;
|
||||
eventsRetentionDays: number;
|
||||
dashboardDefaultTab: "overview" | "events";
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
identity: SiteIdentity;
|
||||
socialLinks: SocialLink[];
|
||||
adminPreferences: AdminPreferences;
|
||||
}
|
||||
|
||||
+7
-41
@@ -134,51 +134,17 @@ export async function getAdminStats(): Promise<AdminStats> {
|
||||
return clientApiFetch<AdminStats>("/api/stats");
|
||||
}
|
||||
|
||||
// Settings API (currently mocked - no backend implementation yet)
|
||||
// Settings API
|
||||
export async function getSettings(): Promise<SiteSettings> {
|
||||
// TODO: Implement when settings system is added
|
||||
// For now, return default settings
|
||||
return {
|
||||
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,
|
||||
},
|
||||
],
|
||||
adminPreferences: {
|
||||
sessionTimeoutMinutes: 60,
|
||||
eventsRetentionDays: 30,
|
||||
dashboardDefaultTab: "overview",
|
||||
},
|
||||
};
|
||||
return clientApiFetch<SiteSettings>("/api/settings");
|
||||
}
|
||||
|
||||
export async function updateSettings(
|
||||
settings: SiteSettings,
|
||||
): Promise<SiteSettings> {
|
||||
// TODO: Implement when settings system is added
|
||||
return settings;
|
||||
return clientApiFetch<SiteSettings>("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
import { getOGImageUrl } from "$lib/og-types";
|
||||
import { apiFetch } from "$lib/api.server";
|
||||
import type { SiteSettings } from "$lib/admin-types";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ url, fetch }) => {
|
||||
// Fetch site settings for all pages
|
||||
const settings = await apiFetch<SiteSettings>("/api/settings", { fetch });
|
||||
|
||||
export const load: LayoutServerLoad = async ({ url }) => {
|
||||
return {
|
||||
settings,
|
||||
metadata: {
|
||||
title: "Xevion.dev",
|
||||
description:
|
||||
"The personal website of Xevion, a full-stack software developer.",
|
||||
title: settings.identity.siteTitle,
|
||||
description: settings.identity.bio.split("\n")[0], // First line of bio
|
||||
ogImage: getOGImageUrl({ type: "index" }),
|
||||
url: url.toString(),
|
||||
},
|
||||
|
||||
@@ -3,7 +3,11 @@ import { apiFetch } from "$lib/api.server";
|
||||
import { renderIconSVG } from "$lib/server/icons";
|
||||
import type { AdminProject } from "$lib/admin-types";
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
export const load: PageServerLoad = async ({ fetch, parent }) => {
|
||||
// Get settings from parent layout
|
||||
const parentData = await parent();
|
||||
const settings = parentData.settings;
|
||||
|
||||
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
||||
|
||||
// Pre-render tag icons and clock icons (server-side only)
|
||||
@@ -12,7 +16,9 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const tagsWithIcons = await Promise.all(
|
||||
project.tags.map(async (tag) => ({
|
||||
...tag,
|
||||
iconSvg: tag.icon ? (await renderIconSVG(tag.icon, { size: 12 })) || "" : "",
|
||||
iconSvg: tag.icon
|
||||
? (await renderIconSVG(tag.icon, { size: 12 })) || ""
|
||||
: "",
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -27,7 +33,16 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Pre-render social link icons (server-side only)
|
||||
const socialLinksWithIcons = await Promise.all(
|
||||
settings.socialLinks.map(async (link) => ({
|
||||
...link,
|
||||
iconSvg: (await renderIconSVG(link.icon, { size: 16 })) || "",
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
projects: projectsWithIcons,
|
||||
socialLinksWithIcons,
|
||||
};
|
||||
};
|
||||
|
||||
+66
-40
@@ -3,15 +3,29 @@
|
||||
import ProjectCard from "$lib/components/ProjectCard.svelte";
|
||||
import PgpKeyModal from "$lib/components/PgpKeyModal.svelte";
|
||||
import type { PageData } from "./$types";
|
||||
import IconSimpleIconsGithub from "~icons/simple-icons/github";
|
||||
import IconSimpleIconsLinkedin from "~icons/simple-icons/linkedin";
|
||||
import IconSimpleIconsDiscord from "~icons/simple-icons/discord";
|
||||
import MaterialSymbolsMailRounded from "~icons/material-symbols/mail-rounded";
|
||||
import type { SiteSettings } from "$lib/admin-types";
|
||||
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const projects = data.projects;
|
||||
// Type assertion needed until types are regenerated
|
||||
const socialLinksWithIcons = (data as any).socialLinksWithIcons;
|
||||
|
||||
// Get settings from parent layout
|
||||
const settings = (data as any).settings as SiteSettings;
|
||||
|
||||
// Filter visible social links
|
||||
const visibleSocialLinks = $derived(
|
||||
socialLinksWithIcons.filter((link: { visible: boolean }) => link.visible),
|
||||
);
|
||||
|
||||
let pgpModalOpen = $state(false);
|
||||
|
||||
// Handle Discord click (copy to clipboard)
|
||||
function handleDiscordClick(username: string) {
|
||||
navigator.clipboard.writeText(username);
|
||||
// TODO: Add toast notification
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppWrapper class="overflow-x-hidden font-schibsted">
|
||||
@@ -21,54 +35,66 @@
|
||||
>
|
||||
<div class="flex flex-col pb-4">
|
||||
<span class="text-2xl font-bold text-zinc-900 dark:text-white sm:text-3xl"
|
||||
>Ryan Walters,</span
|
||||
>{settings.identity.displayName},</span
|
||||
>
|
||||
<span class="text-xl font-normal text-zinc-600 dark:text-zinc-400 sm:text-2xl">
|
||||
Full-Stack Software Engineer
|
||||
{settings.identity.occupation}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="py-4 text-zinc-700 dark:text-zinc-200">
|
||||
<p class="sm:text-[0.95em]">
|
||||
A fanatical software engineer with expertise and passion for sound,
|
||||
scalable and high-performance applications. I'm always working on
|
||||
something new. <br />
|
||||
Sometimes innovative — sometimes crazy.
|
||||
<p class="sm:text-[0.95em] whitespace-pre-line">
|
||||
{settings.identity.bio}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="py-3">
|
||||
<span class="text-zinc-700 dark:text-zinc-200">Connect with me</span>
|
||||
<div class="flex flex-wrap gap-2 pl-3 pt-3 pb-2">
|
||||
<a
|
||||
href="https://github.com/Xevion"
|
||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<IconSimpleIconsGithub class="size-4 text-zinc-600 dark:text-zinc-300" />
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://linkedin.com/in/ryancwalters"
|
||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<IconSimpleIconsLinkedin class="size-4 text-zinc-600 dark:text-zinc-300" />
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">LinkedIn</span
|
||||
>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<IconSimpleIconsDiscord class="size-4 text-zinc-600 dark:text-zinc-300" />
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">Discord</span>
|
||||
</button>
|
||||
<a
|
||||
href="mailto:your.email@example.com"
|
||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<MaterialSymbolsMailRounded class="size-4.5 text-zinc-600 dark:text-zinc-300" />
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">Email</span>
|
||||
</a>
|
||||
{#each visibleSocialLinks as link (link.id)}
|
||||
{#if link.platform === "github" || link.platform === "linkedin"}
|
||||
<!-- Simple link platforms -->
|
||||
<a
|
||||
href={link.value}
|
||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
||||
{@html link.iconSvg}
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||
>{link.label}</span
|
||||
>
|
||||
</a>
|
||||
{:else if link.platform === "discord"}
|
||||
<!-- Discord - button that copies username -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||
onclick={() => handleDiscordClick(link.value)}
|
||||
>
|
||||
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
||||
{@html link.iconSvg}
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||
>{link.label}</span
|
||||
>
|
||||
</button>
|
||||
{:else if link.platform === "email"}
|
||||
<!-- Email - mailto link -->
|
||||
<a
|
||||
href="mailto:{link.value}"
|
||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
|
||||
{@html link.iconSvg}
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||
>{link.label}</span
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- PGP Key - kept separate from settings system -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||
|
||||
@@ -4,15 +4,10 @@
|
||||
import Button from "$lib/components/admin/Button.svelte";
|
||||
import Input from "$lib/components/admin/Input.svelte";
|
||||
import { getSettings, updateSettings } from "$lib/api";
|
||||
import type { SiteSettings, SocialLink } from "$lib/admin-types";
|
||||
import type { SiteSettings } from "$lib/admin-types";
|
||||
import { cn } from "$lib/utils";
|
||||
import IconGithub from "~icons/simple-icons/github";
|
||||
import IconLinkedin from "~icons/simple-icons/linkedin";
|
||||
import IconDiscord from "~icons/simple-icons/discord";
|
||||
import IconMail from "~icons/material-symbols/mail-rounded";
|
||||
import IconKey from "~icons/material-symbols/vpn-key";
|
||||
|
||||
type Tab = "identity" | "social" | "admin";
|
||||
type Tab = "identity" | "social";
|
||||
|
||||
let settings = $state<SiteSettings | null>(null);
|
||||
let loading = $state(true);
|
||||
@@ -22,14 +17,18 @@
|
||||
let activeTab = $derived.by(() => {
|
||||
const params = $page.params as { tab?: string };
|
||||
const tab = params.tab as Tab | undefined;
|
||||
return tab && ["identity", "social", "admin"].includes(tab)
|
||||
? tab
|
||||
: "identity";
|
||||
return tab && ["identity", "social"].includes(tab) ? tab : "identity";
|
||||
});
|
||||
|
||||
// Form state - will be populated when settings load
|
||||
let formData = $state<SiteSettings | null>(null);
|
||||
|
||||
// Deep equality check for change detection
|
||||
const hasChanges = $derived.by(() => {
|
||||
if (!settings || !formData) return false;
|
||||
return JSON.stringify(settings) !== JSON.stringify(formData);
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await getSettings();
|
||||
@@ -47,7 +46,7 @@
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData) return;
|
||||
if (!formData || !hasChanges) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
@@ -69,36 +68,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getSocialIcon(platform: SocialLink["platform"]) {
|
||||
switch (platform) {
|
||||
case "github":
|
||||
return IconGithub;
|
||||
case "linkedin":
|
||||
return IconLinkedin;
|
||||
case "discord":
|
||||
return IconDiscord;
|
||||
case "email":
|
||||
return IconMail;
|
||||
case "pgp":
|
||||
return IconKey;
|
||||
}
|
||||
}
|
||||
|
||||
function getSocialPlaceholder(platform: SocialLink["platform"]) {
|
||||
switch (platform) {
|
||||
case "github":
|
||||
return "https://github.com/username";
|
||||
case "linkedin":
|
||||
return "https://linkedin.com/in/username";
|
||||
case "discord":
|
||||
return "username";
|
||||
case "email":
|
||||
return "your.email@example.com";
|
||||
case "pgp":
|
||||
return "https://example.com/pgp-key.asc";
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToTab(tab: Tab) {
|
||||
goto(`/admin/settings/${tab}`, { replaceState: true });
|
||||
}
|
||||
@@ -113,12 +82,14 @@
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-admin-text">Settings</h1>
|
||||
<p class="mt-1 text-sm text-admin-text-muted">
|
||||
Configure your site identity, social links, and admin preferences
|
||||
Configure your site identity and social links
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-admin-text-muted">Loading settings...</div>
|
||||
<div class="text-center py-12 text-admin-text-muted">
|
||||
Loading settings...
|
||||
</div>
|
||||
{:else if formData}
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-admin-border">
|
||||
@@ -147,18 +118,6 @@
|
||||
>
|
||||
Social Links
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
"pb-3 px-1 text-sm font-medium border-b-2 transition-colors",
|
||||
activeTab === "admin"
|
||||
? "border-admin-accent text-admin-text"
|
||||
: "border-transparent text-admin-text-muted hover:text-admin-text hover:border-admin-border-hover",
|
||||
)}
|
||||
onclick={() => navigateToTab("admin")}
|
||||
>
|
||||
Admin Preferences
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +150,7 @@
|
||||
bind:value={formData.identity.bio}
|
||||
placeholder="A brief description about yourself..."
|
||||
rows={6}
|
||||
help="Supports Markdown (rendered on the index page)"
|
||||
help="Plain text for now (Markdown support coming later)"
|
||||
/>
|
||||
<Input
|
||||
label="Site Title"
|
||||
@@ -204,39 +163,81 @@
|
||||
</div>
|
||||
{:else if activeTab === "social"}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-base font-medium text-admin-text mb-4">Social Links</h3>
|
||||
<h3 class="text-base font-medium text-admin-text mb-4">
|
||||
Social Links
|
||||
</h3>
|
||||
<p class="text-sm text-admin-text-muted mb-4">
|
||||
Configure your social media presence on the index page
|
||||
Configure your social media presence on the homepage. Display order
|
||||
and icon identifiers can be edited here.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each formData.socialLinks as link (link.id)}
|
||||
{@const Icon = getSocialIcon(link.platform)}
|
||||
<div
|
||||
class="rounded-lg border border-admin-border bg-admin-surface-hover/50 p-4 hover:border-admin-border-hover transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="mt-2">
|
||||
<Icon class="w-5 h-5 text-admin-text-muted" />
|
||||
</div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-admin-text"
|
||||
>{link.label}</span
|
||||
>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<span class="text-xs text-admin-text-muted">Visible</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={link.visible}
|
||||
class="w-4 h-4 rounded border-admin-border bg-admin-bg-secondary text-admin-accent focus:ring-2 focus:ring-admin-accent focus:ring-offset-0 cursor-pointer"
|
||||
<div class="flex items-center gap-3">
|
||||
<Input
|
||||
label="Label"
|
||||
type="text"
|
||||
bind:value={link.label}
|
||||
placeholder="GitHub"
|
||||
class="w-32"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-center gap-2 cursor-pointer pt-6"
|
||||
>
|
||||
<span class="text-xs text-admin-text-muted"
|
||||
>Visible</span
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={link.visible}
|
||||
class="w-4 h-4 rounded border-admin-border bg-admin-bg-secondary text-admin-accent focus:ring-2 focus:ring-admin-accent focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Platform"
|
||||
type="text"
|
||||
bind:value={link.platform}
|
||||
placeholder="github"
|
||||
help="Platform identifier (github, linkedin, discord, email, etc.)"
|
||||
/>
|
||||
<Input
|
||||
label="Display Order"
|
||||
type="number"
|
||||
bind:value={link.displayOrder}
|
||||
placeholder="1"
|
||||
help="Lower numbers appear first"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Icon"
|
||||
type="text"
|
||||
bind:value={link.icon}
|
||||
placeholder="simple-icons:github"
|
||||
help="Icon identifier (e.g., 'simple-icons:github', 'lucide:mail')"
|
||||
/>
|
||||
<Input
|
||||
label="Value"
|
||||
type={link.platform === "email" ? "email" : "text"}
|
||||
bind:value={link.value}
|
||||
placeholder={getSocialPlaceholder(link.platform)}
|
||||
placeholder={link.platform === "github"
|
||||
? "https://github.com/username"
|
||||
: link.platform === "email"
|
||||
? "your.email@example.com"
|
||||
: "value"}
|
||||
help={link.platform === "discord"
|
||||
? "Discord username (copied to clipboard on click)"
|
||||
: link.platform === "email"
|
||||
? "Email address (opens mailto: link)"
|
||||
: "URL to link to"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,45 +245,19 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === "admin"}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-base font-medium text-admin-text mb-4">
|
||||
Admin Preferences
|
||||
</h3>
|
||||
<Input
|
||||
label="Session Timeout"
|
||||
type="number"
|
||||
bind:value={formData.adminPreferences.sessionTimeoutMinutes}
|
||||
placeholder="60"
|
||||
help="Minutes of inactivity before automatic logout (5-1440)"
|
||||
/>
|
||||
<Input
|
||||
label="Event Log Retention"
|
||||
type="number"
|
||||
bind:value={formData.adminPreferences.eventsRetentionDays}
|
||||
placeholder="30"
|
||||
help="Number of days to retain event logs (1-365)"
|
||||
/>
|
||||
<Input
|
||||
label="Dashboard Default Tab"
|
||||
type="select"
|
||||
bind:value={formData.adminPreferences.dashboardDefaultTab}
|
||||
options={[
|
||||
{ label: "Overview", value: "overview" },
|
||||
{ label: "Events", value: "events" },
|
||||
]}
|
||||
help="Which tab to show by default when visiting the dashboard"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={saving}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={handleCancel}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handleSave} disabled={saving}>
|
||||
<Button variant="primary" onclick={handleSave} disabled={!hasChanges || saving}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user