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
@@ -0,0 +1,61 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE site_identity\n SET display_name = $1, occupation = $2, bio = $3, site_title = $4\n WHERE id = 1\n RETURNING id, display_name, occupation, bio, site_title, created_at, updated_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "occupation",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "bio",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "site_title",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "2ed3555fc0cfe71b8a59c89f6d3fea53458b995f342d623f7ab43824392973c9"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE social_links\n SET platform = $2, label = $3, value = $4, icon = $5, visible = $6, display_order = $7\n WHERE id = $1\n RETURNING id, platform, label, value, icon, visible, display_order, created_at, updated_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "platform",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "label",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "value",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "icon",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "visible",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "display_order",
"type_info": "Int4"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Text",
"Text",
"Bool",
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "8f08a65d180757f0fcbe65a43e3a2e3fdd95cc1aba80eb40c1042af7301d0fa9"
}
@@ -0,0 +1,68 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, platform, label, value, icon, visible, display_order, created_at, updated_at\n FROM social_links\n ORDER BY display_order ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "platform",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "label",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "value",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "icon",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "visible",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "display_order",
"type_info": "Int4"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "9e55ffc9d9138ea02678ff432db47488da976516f53b53d7a0bd36665b39b628"
}
@@ -0,0 +1,56 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, display_name, occupation, bio, site_title, created_at, updated_at\n FROM site_identity\n WHERE id = 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "occupation",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "bio",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "site_title",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "f45329eab47a84c6c921f4e78c443835135fa095f16a7a0a9080249c4136fc7d"
}
@@ -0,0 +1,56 @@
-- Site identity settings (single row table)
CREATE TABLE site_identity (
id INTEGER PRIMARY KEY CHECK (id = 1), -- Enforce single row
display_name TEXT NOT NULL,
occupation TEXT NOT NULL,
bio TEXT NOT NULL,
site_title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Trigger for updated_at
CREATE TRIGGER update_site_identity_updated_at
BEFORE UPDATE ON site_identity
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Social links (multiple rows, extensible)
CREATE TABLE social_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform TEXT NOT NULL, -- Not an enum for extensibility
label TEXT NOT NULL,
value TEXT NOT NULL,
icon TEXT NOT NULL, -- Icon identifier (e.g., 'simple-icons:github')
visible BOOLEAN NOT NULL DEFAULT true,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for ordering
CREATE INDEX idx_social_links_order ON social_links(display_order ASC);
-- Trigger for updated_at
CREATE TRIGGER update_social_links_updated_at
BEFORE UPDATE ON social_links
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Seed default identity
INSERT INTO site_identity (id, display_name, occupation, bio, site_title)
VALUES (
1,
'Ryan Walters',
'Full-Stack Software Engineer',
'A fanatical software engineer with expertise and passion for sound, scalable and high-performance applications. I''m always working on something new.
Sometimes innovative — sometimes crazy.',
'Xevion.dev'
);
-- Seed default social links
INSERT INTO social_links (platform, label, value, icon, visible, display_order) VALUES
('github', 'GitHub', 'https://github.com/Xevion', 'simple-icons:github', true, 1),
('linkedin', 'LinkedIn', 'https://linkedin.com/in/ryancwalters', 'simple-icons:linkedin', true, 2),
('discord', 'Discord', 'xevion', 'simple-icons:discord', true, 3),
('email', 'Email', 'your.email@example.com', 'material-symbols:mail-rounded', true, 4);
+216
View File
@@ -904,3 +904,219 @@ pub async fn get_admin_stats(pool: &PgPool) -> Result<AdminStats, sqlx::Error> {
total_tags: tag_count.count, total_tags: tag_count.count,
}) })
} }
// Site settings models and queries
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DbSiteIdentity {
pub id: i32,
pub display_name: String,
pub occupation: String,
pub bio: String,
pub site_title: String,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DbSocialLink {
pub id: Uuid,
pub platform: String,
pub label: String,
pub value: String,
pub icon: String,
pub visible: bool,
pub display_order: i32,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
// API response types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiSiteIdentity {
#[serde(rename = "displayName")]
pub display_name: String,
pub occupation: String,
pub bio: String,
#[serde(rename = "siteTitle")]
pub site_title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiSocialLink {
pub id: String,
pub platform: String,
pub label: String,
pub value: String,
pub icon: String,
pub visible: bool,
#[serde(rename = "displayOrder")]
pub display_order: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiSiteSettings {
pub identity: ApiSiteIdentity,
#[serde(rename = "socialLinks")]
pub social_links: Vec<ApiSocialLink>,
}
// Request types for updates
#[derive(Debug, Deserialize)]
pub struct UpdateSiteIdentityRequest {
#[serde(rename = "displayName")]
pub display_name: String,
pub occupation: String,
pub bio: String,
#[serde(rename = "siteTitle")]
pub site_title: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSocialLinkRequest {
pub id: String,
pub platform: String,
pub label: String,
pub value: String,
pub icon: String,
pub visible: bool,
#[serde(rename = "displayOrder")]
pub display_order: i32,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSiteSettingsRequest {
pub identity: UpdateSiteIdentityRequest,
#[serde(rename = "socialLinks")]
pub social_links: Vec<UpdateSocialLinkRequest>,
}
// Conversion implementations
impl DbSiteIdentity {
pub fn to_api(&self) -> ApiSiteIdentity {
ApiSiteIdentity {
display_name: self.display_name.clone(),
occupation: self.occupation.clone(),
bio: self.bio.clone(),
site_title: self.site_title.clone(),
}
}
}
impl DbSocialLink {
pub fn to_api(&self) -> ApiSocialLink {
ApiSocialLink {
id: self.id.to_string(),
platform: self.platform.clone(),
label: self.label.clone(),
value: self.value.clone(),
icon: self.icon.clone(),
visible: self.visible,
display_order: self.display_order,
}
}
}
// Query functions
pub async fn get_site_settings(pool: &PgPool) -> Result<ApiSiteSettings, sqlx::Error> {
// Get identity (single row)
let identity = sqlx::query_as!(
DbSiteIdentity,
r#"
SELECT id, display_name, occupation, bio, site_title, created_at, updated_at
FROM site_identity
WHERE id = 1
"#
)
.fetch_one(pool)
.await?;
// Get social links (ordered)
let social_links = sqlx::query_as!(
DbSocialLink,
r#"
SELECT id, platform, label, value, icon, visible, display_order, created_at, updated_at
FROM social_links
ORDER BY display_order ASC
"#
)
.fetch_all(pool)
.await?;
Ok(ApiSiteSettings {
identity: identity.to_api(),
social_links: social_links.into_iter().map(|sl| sl.to_api()).collect(),
})
}
pub async fn update_site_identity(
pool: &PgPool,
req: &UpdateSiteIdentityRequest,
) -> Result<DbSiteIdentity, sqlx::Error> {
sqlx::query_as!(
DbSiteIdentity,
r#"
UPDATE site_identity
SET display_name = $1, occupation = $2, bio = $3, site_title = $4
WHERE id = 1
RETURNING id, display_name, occupation, bio, site_title, created_at, updated_at
"#,
req.display_name,
req.occupation,
req.bio,
req.site_title
)
.fetch_one(pool)
.await
}
pub async fn update_social_link(
pool: &PgPool,
link_id: Uuid,
req: &UpdateSocialLinkRequest,
) -> Result<DbSocialLink, sqlx::Error> {
sqlx::query_as!(
DbSocialLink,
r#"
UPDATE social_links
SET platform = $2, label = $3, value = $4, icon = $5, visible = $6, display_order = $7
WHERE id = $1
RETURNING id, platform, label, value, icon, visible, display_order, created_at, updated_at
"#,
link_id,
req.platform,
req.label,
req.value,
req.icon,
req.visible,
req.display_order
)
.fetch_one(pool)
.await
}
pub async fn update_site_settings(
pool: &PgPool,
req: &UpdateSiteSettingsRequest,
) -> Result<ApiSiteSettings, sqlx::Error> {
// Update identity
let identity = update_site_identity(pool, &req.identity).await?;
// Update each social link
let mut updated_links = Vec::new();
for link_req in &req.social_links {
let link_id = Uuid::parse_str(&link_req.id).map_err(|_| {
sqlx::Error::Decode(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid UUID format",
)))
})?;
let link = update_social_link(pool, link_id, link_req).await?;
updated_links.push(link);
}
Ok(ApiSiteSettings {
identity: identity.to_api(),
social_links: updated_links.into_iter().map(|sl| sl.to_api()).collect(),
})
}
+55
View File
@@ -395,6 +395,11 @@ fn api_routes() -> Router<Arc<AppState>> {
) )
// Admin stats - requires authentication // Admin stats - requires authentication
.route("/stats", axum::routing::get(get_admin_stats_handler)) .route("/stats", axum::routing::get(get_admin_stats_handler))
// Site settings - GET is public, PUT requires authentication
.route(
"/settings",
axum::routing::get(get_settings_handler).put(update_settings_handler),
)
// Icon API - proxy to SvelteKit (authentication handled by SvelteKit) // Icon API - proxy to SvelteKit (authentication handled by SvelteKit)
.route("/icons/{*path}", axum::routing::get(proxy_icons_handler)) .route("/icons/{*path}", axum::routing::get(proxy_icons_handler))
.fallback(api_404_and_method_handler) .fallback(api_404_and_method_handler)
@@ -1160,6 +1165,56 @@ async fn get_admin_stats_handler(
} }
} }
// Site settings handlers
async fn get_settings_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match db::get_site_settings(&state.pool).await {
Ok(settings) => Json(settings).into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch site settings");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch settings"
})),
)
.into_response()
}
}
}
async fn update_settings_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::UpdateSiteSettingsRequest>,
) -> impl IntoResponse {
// Check authentication
if check_session(&state, &jar).is_none() {
return require_auth_response().into_response();
}
match db::update_site_settings(&state.pool, &payload).await {
Ok(settings) => {
// TODO: Invalidate ISR cache for homepage and affected routes when ISR is implemented
// TODO: Add event log entry for settings update when events table is implemented
tracing::info!("Site settings updated");
Json(settings).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to update site settings");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to update settings"
})),
)
.into_response()
}
}
}
// Tag API handlers // Tag API handlers
async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse { async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
+3 -15
View File
@@ -78,19 +78,14 @@ export interface AuthSession {
expiresAt: string; // ISO 8601 expiresAt: string; // ISO 8601
} }
export type SocialPlatform =
| "github"
| "linkedin"
| "discord"
| "email"
| "pgp";
export interface SocialLink { export interface SocialLink {
id: string; id: string;
platform: SocialPlatform; platform: string; // Not an enum for extensibility
label: string; label: string;
value: string; // URL, username, or email address value: string; // URL, username, or email address
icon: string; // Icon identifier (e.g., 'simple-icons:github')
visible: boolean; visible: boolean;
displayOrder: number;
} }
export interface SiteIdentity { export interface SiteIdentity {
@@ -100,14 +95,7 @@ export interface SiteIdentity {
siteTitle: string; siteTitle: string;
} }
export interface AdminPreferences {
sessionTimeoutMinutes: number;
eventsRetentionDays: number;
dashboardDefaultTab: "overview" | "events";
}
export interface SiteSettings { export interface SiteSettings {
identity: SiteIdentity; identity: SiteIdentity;
socialLinks: SocialLink[]; socialLinks: SocialLink[];
adminPreferences: AdminPreferences;
} }
+7 -41
View File
@@ -134,51 +134,17 @@ export async function getAdminStats(): Promise<AdminStats> {
return clientApiFetch<AdminStats>("/api/stats"); return clientApiFetch<AdminStats>("/api/stats");
} }
// Settings API (currently mocked - no backend implementation yet) // Settings API
export async function getSettings(): Promise<SiteSettings> { export async function getSettings(): Promise<SiteSettings> {
// TODO: Implement when settings system is added return clientApiFetch<SiteSettings>("/api/settings");
// 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",
},
};
} }
export async function updateSettings( export async function updateSettings(
settings: SiteSettings, settings: SiteSettings,
): Promise<SiteSettings> { ): Promise<SiteSettings> {
// TODO: Implement when settings system is added return clientApiFetch<SiteSettings>("/api/settings", {
return 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 type { LayoutServerLoad } from "./$types";
import { getOGImageUrl } from "$lib/og-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 { return {
settings,
metadata: { metadata: {
title: "Xevion.dev", title: settings.identity.siteTitle,
description: description: settings.identity.bio.split("\n")[0], // First line of bio
"The personal website of Xevion, a full-stack software developer.",
ogImage: getOGImageUrl({ type: "index" }), ogImage: getOGImageUrl({ type: "index" }),
url: url.toString(), url: url.toString(),
}, },
+17 -2
View File
@@ -3,7 +3,11 @@ import { apiFetch } from "$lib/api.server";
import { renderIconSVG } from "$lib/server/icons"; import { renderIconSVG } from "$lib/server/icons";
import type { AdminProject } from "$lib/admin-types"; 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 }); const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
// Pre-render tag icons and clock icons (server-side only) // 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( const tagsWithIcons = await Promise.all(
project.tags.map(async (tag) => ({ project.tags.map(async (tag) => ({
...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 { return {
projects: projectsWithIcons, projects: projectsWithIcons,
socialLinksWithIcons,
}; };
}; };
+66 -40
View File
@@ -3,15 +3,29 @@
import ProjectCard from "$lib/components/ProjectCard.svelte"; import ProjectCard from "$lib/components/ProjectCard.svelte";
import PgpKeyModal from "$lib/components/PgpKeyModal.svelte"; import PgpKeyModal from "$lib/components/PgpKeyModal.svelte";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import IconSimpleIconsGithub from "~icons/simple-icons/github"; import type { SiteSettings } from "$lib/admin-types";
import IconSimpleIconsLinkedin from "~icons/simple-icons/linkedin";
import IconSimpleIconsDiscord from "~icons/simple-icons/discord";
import MaterialSymbolsMailRounded from "~icons/material-symbols/mail-rounded";
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key"; import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const projects = data.projects; 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); let pgpModalOpen = $state(false);
// Handle Discord click (copy to clipboard)
function handleDiscordClick(username: string) {
navigator.clipboard.writeText(username);
// TODO: Add toast notification
}
</script> </script>
<AppWrapper class="overflow-x-hidden font-schibsted"> <AppWrapper class="overflow-x-hidden font-schibsted">
@@ -21,54 +35,66 @@
> >
<div class="flex flex-col pb-4"> <div class="flex flex-col pb-4">
<span class="text-2xl font-bold text-zinc-900 dark:text-white sm:text-3xl" <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"> <span class="text-xl font-normal text-zinc-600 dark:text-zinc-400 sm:text-2xl">
Full-Stack Software Engineer {settings.identity.occupation}
</span> </span>
</div> </div>
<div class="py-4 text-zinc-700 dark:text-zinc-200"> <div class="py-4 text-zinc-700 dark:text-zinc-200">
<p class="sm:text-[0.95em]"> <p class="sm:text-[0.95em] whitespace-pre-line">
A fanatical software engineer with expertise and passion for sound, {settings.identity.bio}
scalable and high-performance applications. I'm always working on
something new. <br />
Sometimes innovative &mdash; sometimes crazy.
</p> </p>
</div> </div>
<div class="py-3"> <div class="py-3">
<span class="text-zinc-700 dark:text-zinc-200">Connect with me</span> <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"> <div class="flex flex-wrap gap-2 pl-3 pt-3 pb-2">
<a {#each visibleSocialLinks as link (link.id)}
href="https://github.com/Xevion" {#if link.platform === "github" || link.platform === "linkedin"}
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" <!-- Simple link platforms -->
> <a
<IconSimpleIconsGithub class="size-4 text-zinc-600 dark:text-zinc-300" /> href={link.value}
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">GitHub</span> 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"
</a> >
<a <span class="size-4 text-zinc-600 dark:text-zinc-300">
href="https://linkedin.com/in/ryancwalters" {@html link.iconSvg}
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>
> <span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
<IconSimpleIconsLinkedin class="size-4 text-zinc-600 dark:text-zinc-300" /> >{link.label}</span
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">LinkedIn</span >
> </a>
</a> {:else if link.platform === "discord"}
<button <!-- Discord - button that copies username -->
type="button" <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" 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" /> onclick={() => handleDiscordClick(link.value)}
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">Discord</span> >
</button> <span class="size-4 text-zinc-600 dark:text-zinc-300">
<a {@html link.iconSvg}
href="mailto:your.email@example.com" </span>
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="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
> >{link.label}</span
<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> </button>
</a> {: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 <button
type="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" 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 Button from "$lib/components/admin/Button.svelte";
import Input from "$lib/components/admin/Input.svelte"; import Input from "$lib/components/admin/Input.svelte";
import { getSettings, updateSettings } from "$lib/api"; 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 { 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 settings = $state<SiteSettings | null>(null);
let loading = $state(true); let loading = $state(true);
@@ -22,14 +17,18 @@
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) return tab && ["identity", "social"].includes(tab) ? tab : "identity";
? tab
: "identity";
}); });
// Form state - will be populated when settings load // Form state - will be populated when settings load
let formData = $state<SiteSettings | null>(null); 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() { async function loadSettings() {
try { try {
const data = await getSettings(); const data = await getSettings();
@@ -47,7 +46,7 @@
}); });
async function handleSave() { async function handleSave() {
if (!formData) return; if (!formData || !hasChanges) return;
saving = true; saving = true;
try { 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) { function navigateToTab(tab: Tab) {
goto(`/admin/settings/${tab}`, { replaceState: true }); goto(`/admin/settings/${tab}`, { replaceState: true });
} }
@@ -113,12 +82,14 @@
<div> <div>
<h1 class="text-xl font-semibold text-admin-text">Settings</h1> <h1 class="text-xl font-semibold text-admin-text">Settings</h1>
<p class="mt-1 text-sm text-admin-text-muted"> <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> </p>
</div> </div>
{#if loading} {#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} {:else if formData}
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-admin-border"> <div class="border-b border-admin-border">
@@ -147,18 +118,6 @@
> >
Social Links Social Links
</button> </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> </nav>
</div> </div>
@@ -191,7 +150,7 @@
bind:value={formData.identity.bio} bind:value={formData.identity.bio}
placeholder="A brief description about yourself..." placeholder="A brief description about yourself..."
rows={6} rows={6}
help="Supports Markdown (rendered on the index page)" help="Plain text for now (Markdown support coming later)"
/> />
<Input <Input
label="Site Title" label="Site Title"
@@ -204,39 +163,81 @@
</div> </div>
{:else if activeTab === "social"} {:else if activeTab === "social"}
<div class="space-y-4"> <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"> <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> </p>
<div class="space-y-3"> <div class="space-y-3">
{#each formData.socialLinks as link (link.id)} {#each formData.socialLinks as link (link.id)}
{@const Icon = getSocialIcon(link.platform)}
<div <div
class="rounded-lg border border-admin-border bg-admin-surface-hover/50 p-4 hover:border-admin-border-hover transition-colors" 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="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-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-admin-text" <div class="flex items-center gap-3">
>{link.label}</span <Input
> label="Label"
<label class="flex items-center gap-2 cursor-pointer"> type="text"
<span class="text-xs text-admin-text-muted">Visible</span> bind:value={link.label}
<input placeholder="GitHub"
type="checkbox" class="w-32"
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> <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> </div>
<Input <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"} type={link.platform === "email" ? "email" : "text"}
bind:value={link.value} 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>
</div> </div>
@@ -244,45 +245,19 @@
{/each} {/each}
</div> </div>
</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} {/if}
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<Button variant="secondary" onclick={handleCancel} disabled={saving}> <Button
variant="secondary"
onclick={handleCancel}
disabled={!hasChanges || saving}
>
Cancel Cancel
</Button> </Button>
<Button variant="primary" onclick={handleSave} disabled={saving}> <Button variant="primary" onclick={handleSave} disabled={!hasChanges || saving}>
{saving ? "Saving..." : "Save Changes"} {saving ? "Saving..." : "Save Changes"}
</Button> </Button>
</div> </div>