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:
2026-01-06 22:48:10 -06:00
parent 9ab22ea234
commit 4663b00942
13 changed files with 770 additions and 207 deletions
+3 -15
View File
@@ -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
View File
@@ -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),
});
}
+9 -4
View File
@@ -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(),
},
+17 -2
View File
@@ -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
View File
@@ -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 &mdash; 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>