diff --git a/.sqlx/query-2ed3555fc0cfe71b8a59c89f6d3fea53458b995f342d623f7ab43824392973c9.json b/.sqlx/query-2ed3555fc0cfe71b8a59c89f6d3fea53458b995f342d623f7ab43824392973c9.json new file mode 100644 index 0000000..1b38754 --- /dev/null +++ b/.sqlx/query-2ed3555fc0cfe71b8a59c89f6d3fea53458b995f342d623f7ab43824392973c9.json @@ -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" +} diff --git a/.sqlx/query-8f08a65d180757f0fcbe65a43e3a2e3fdd95cc1aba80eb40c1042af7301d0fa9.json b/.sqlx/query-8f08a65d180757f0fcbe65a43e3a2e3fdd95cc1aba80eb40c1042af7301d0fa9.json new file mode 100644 index 0000000..d58b864 --- /dev/null +++ b/.sqlx/query-8f08a65d180757f0fcbe65a43e3a2e3fdd95cc1aba80eb40c1042af7301d0fa9.json @@ -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" +} diff --git a/.sqlx/query-9e55ffc9d9138ea02678ff432db47488da976516f53b53d7a0bd36665b39b628.json b/.sqlx/query-9e55ffc9d9138ea02678ff432db47488da976516f53b53d7a0bd36665b39b628.json new file mode 100644 index 0000000..02e79d1 --- /dev/null +++ b/.sqlx/query-9e55ffc9d9138ea02678ff432db47488da976516f53b53d7a0bd36665b39b628.json @@ -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" +} diff --git a/.sqlx/query-f45329eab47a84c6c921f4e78c443835135fa095f16a7a0a9080249c4136fc7d.json b/.sqlx/query-f45329eab47a84c6c921f4e78c443835135fa095f16a7a0a9080249c4136fc7d.json new file mode 100644 index 0000000..d3208ff --- /dev/null +++ b/.sqlx/query-f45329eab47a84c6c921f4e78c443835135fa095f16a7a0a9080249c4136fc7d.json @@ -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" +} diff --git a/migrations/20260106220216_add_site_settings.sql b/migrations/20260106220216_add_site_settings.sql new file mode 100644 index 0000000..a2331fa --- /dev/null +++ b/migrations/20260106220216_add_site_settings.sql @@ -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); diff --git a/src/db.rs b/src/db.rs index b480acf..e566be2 100644 --- a/src/db.rs +++ b/src/db.rs @@ -904,3 +904,219 @@ pub async fn get_admin_stats(pool: &PgPool) -> Result { 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, +} + +// 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, +} + +// 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 { + // 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 { + 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 { + 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 { + // 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(), + }) +} diff --git a/src/main.rs b/src/main.rs index 8095643..58ae48c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -395,6 +395,11 @@ fn api_routes() -> Router> { ) // Admin stats - requires authentication .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) .route("/icons/{*path}", axum::routing::get(proxy_icons_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>) -> 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>, + jar: axum_extra::extract::CookieJar, + Json(payload): Json, +) -> 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 async fn list_tags_handler(State(state): State>) -> impl IntoResponse { diff --git a/web/src/lib/admin-types.ts b/web/src/lib/admin-types.ts index 64543bc..7155c07 100644 --- a/web/src/lib/admin-types.ts +++ b/web/src/lib/admin-types.ts @@ -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; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d3b95d1..f7f2caf 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -134,51 +134,17 @@ export async function getAdminStats(): Promise { return clientApiFetch("/api/stats"); } -// Settings API (currently mocked - no backend implementation yet) +// Settings API export async function getSettings(): Promise { - // 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("/api/settings"); } export async function updateSettings( settings: SiteSettings, ): Promise { - // TODO: Implement when settings system is added - return settings; + return clientApiFetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); } diff --git a/web/src/routes/+layout.server.ts b/web/src/routes/+layout.server.ts index 3f609bb..38d70f6 100644 --- a/web/src/routes/+layout.server.ts +++ b/web/src/routes/+layout.server.ts @@ -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("/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(), }, diff --git a/web/src/routes/+page.server.ts b/web/src/routes/+page.server.ts index aa62a46..9e72462 100644 --- a/web/src/routes/+page.server.ts +++ b/web/src/routes/+page.server.ts @@ -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("/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, }; }; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index b3beb45..408bbff 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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 + } @@ -21,54 +35,66 @@ >
Ryan Walters,{settings.identity.displayName}, - Full-Stack Software Engineer + {settings.identity.occupation}
-

- 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. +

+ {settings.identity.bio}

Connect with me
- - - GitHub - - - - LinkedIn - - - - - Email - + {#each visibleSocialLinks as link (link.id)} + {#if link.platform === "github" || link.platform === "linkedin"} + + + + {@html link.iconSvg} + + {link.label} + + {:else if link.platform === "discord"} + + + {:else if link.platform === "email"} + + + + {@html link.iconSvg} + + {link.label} + + {/if} + {/each} + -
@@ -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)" /> {:else if activeTab === "social"}
-

Social Links

+

+ Social Links +

- 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.

{#each formData.socialLinks as link (link.id)} - {@const Icon = getSocialIcon(link.platform)}
-
- -
- {link.label} - + +
+
+
+ +
+
@@ -244,45 +245,19 @@ {/each}
- {:else if activeTab === "admin"} -
-

- Admin Preferences -

- - - -
{/if}
- -