From 0102c89d81f9a259026810cd2fb8dad72ccf070b Mon Sep 17 00:00:00 2001
From: Nils Pukropp
Date: Thu, 14 May 2026 17:06:27 +0200
Subject: [PATCH] added contact page
---
backend/src/handlers/config.rs | 2 +
backend/src/models.rs | 17 +++
.../src/components/react/admin/Settings.tsx | 128 +++++++++++++++++-
frontend/src/layouts/Layout.astro | 22 ++-
frontend/src/lib/types.ts | 10 ++
frontend/src/pages/contact.astro | 116 ++++++++++++++++
6 files changed, 290 insertions(+), 5 deletions(-)
create mode 100644 frontend/src/pages/contact.astro
diff --git a/backend/src/handlers/config.rs b/backend/src/handlers/config.rs
index 379e47a..e737131 100644
--- a/backend/src/handlers/config.rs
+++ b/backend/src/handlers/config.rs
@@ -40,6 +40,8 @@ pub async fn update_config(
if let Some(v) = patch.favicon { config.favicon = v; }
if let Some(v) = patch.theme { config.theme = v; }
if let Some(v) = patch.custom_css { config.custom_css = v; }
+ if let Some(v) = patch.contact_intro { config.contact_intro = v; }
+ if let Some(v) = patch.contact_links { config.contact_links = v; }
let config_str = serde_json::to_string_pretty(&config).map_err(|e| {
error!("Serialization error: {}", e);
diff --git a/backend/src/models.rs b/backend/src/models.rs
index 5dc2a51..be7de4e 100644
--- a/backend/src/models.rs
+++ b/backend/src/models.rs
@@ -1,6 +1,13 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
+#[derive(Serialize, Deserialize, Clone)]
+pub struct ContactLink {
+ pub kind: String,
+ pub label: String,
+ pub value: String,
+}
+
#[derive(Serialize, Deserialize, Clone)]
pub struct SiteConfig {
pub title: String,
@@ -11,6 +18,10 @@ pub struct SiteConfig {
pub favicon: String,
pub theme: String,
pub custom_css: String,
+ #[serde(default)]
+ pub contact_intro: String,
+ #[serde(default)]
+ pub contact_links: Vec,
}
impl Default for SiteConfig {
@@ -26,6 +37,8 @@ impl Default for SiteConfig {
favicon: "/favicon.svg".to_string(),
theme: "salon".to_string(),
custom_css: "".to_string(),
+ contact_intro: "".to_string(),
+ contact_links: Vec::new(),
}
}
}
@@ -48,6 +61,10 @@ pub struct SiteConfigPatch {
pub theme: Option,
#[serde(default)]
pub custom_css: Option,
+ #[serde(default)]
+ pub contact_intro: Option,
+ #[serde(default)]
+ pub contact_links: Option>,
}
#[derive(Serialize, Deserialize, Clone, Default)]
diff --git a/frontend/src/components/react/admin/Settings.tsx b/frontend/src/components/react/admin/Settings.tsx
index da81355..728a0ff 100644
--- a/frontend/src/components/react/admin/Settings.tsx
+++ b/frontend/src/components/react/admin/Settings.tsx
@@ -1,6 +1,15 @@
import { useState, useEffect } from 'react';
import { getConfig, updateConfig, ApiError } from '../../../lib/api';
-import type { SiteConfig } from '../../../lib/types';
+import type { SiteConfig, ContactLink } from '../../../lib/types';
+
+const CONTACT_KINDS: { value: string; label: string; placeholder: string }[] = [
+ { value: 'email', label: 'Email', placeholder: 'you@example.com' },
+ { value: 'mastodon', label: 'Mastodon', placeholder: 'https://mastodon.social/@you' },
+ { value: 'bluesky', label: 'Bluesky', placeholder: 'https://bsky.app/profile/you.bsky.social' },
+ { value: 'github', label: 'GitHub', placeholder: 'https://github.com/you' },
+ { value: 'instagram', label: 'Instagram', placeholder: 'https://instagram.com/you' },
+ { value: 'url', label: 'Other link', placeholder: 'https://…' },
+];
export default function Settings() {
const [config, setConfig] = useState>({});
@@ -17,10 +26,30 @@ export default function Settings() {
setTimeout(() => setAlert(null), 5000);
}
- function update(key: keyof SiteConfig, value: string) {
+ function update(key: K, value: SiteConfig[K]) {
setConfig(prev => ({ ...prev, [key]: value }));
}
+ const contactLinks: ContactLink[] = config.contact_links ?? [];
+
+ function updateContactLink(index: number, patch: Partial) {
+ const next = contactLinks.map((row, i) => (i === index ? { ...row, ...patch } : row));
+ update('contact_links', next);
+ }
+ function addContactLink() {
+ update('contact_links', [...contactLinks, { kind: 'email', label: 'Email', value: '' }]);
+ }
+ function removeContactLink(index: number) {
+ update('contact_links', contactLinks.filter((_, i) => i !== index));
+ }
+ function moveContactLink(index: number, dir: -1 | 1) {
+ const target = index + dir;
+ if (target < 0 || target >= contactLinks.length) return;
+ const next = [...contactLinks];
+ [next[index], next[target]] = [next[target], next[index]];
+ update('contact_links', next);
+ }
+
async function handleSubmit(e: React.SyntheticEvent) {
e.preventDefault();
try {
@@ -85,6 +114,101 @@ export default function Settings() {
+
+ Contact
+
+
+
+
+
+
+ {contactLinks.length === 0 && (
+
+ No links yet. Add one below to populate the contact page.
+
+ )}
+ {contactLinks.map((row, i) => (
+
+
+
+
+
+
+
+ updateContactLink(i, { label: e.target.value })}
+ className="field-input"
+ placeholder="Displayed name"
+ />
+
+
+
+ updateContactLink(i, { value: e.target.value })}
+ className="field-input"
+ placeholder={CONTACT_KINDS.find(k => k.value === row.kind)?.placeholder ?? ''}
+ />
+
+
+
+
+
+
+
+ ))}
+
+
+
+
Footer
update('footer', v)} />
diff --git a/frontend/src/layouts/Layout.astro b/frontend/src/layouts/Layout.astro
index 4fab81a..2202335 100644
--- a/frontend/src/layouts/Layout.astro
+++ b/frontend/src/layouts/Layout.astro
@@ -24,13 +24,22 @@ const { title, wide = false, description, image, type = 'website' } = Astro.prop
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
-let siteConfig = {
+let siteConfig: {
+ title: string;
+ subtitle: string;
+ footer: string;
+ favicon: string;
+ theme: string;
+ custom_css: string;
+ contact_links?: { kind: string; label: string; value: string }[];
+} = {
title: "Ela's Atelier",
subtitle: "Works on paper, canvas, and elsewhere",
footer: "Hand-arranged with care",
favicon: "/favicon.svg",
theme: "salon",
- custom_css: ""
+ custom_css: "",
+ contact_links: []
};
try {
@@ -44,6 +53,7 @@ try {
const fullTitle = `${title} · ${siteConfig.title}`;
const year = new Date().getFullYear();
+const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
---
@@ -147,8 +157,14 @@ const year = new Date().getFullYear();
/>
) : siteConfig.footer}
-
+
RSS Feed
+ {hasContact && (
+ <>
+
·
+
Contact
+ >
+ )}
© {year} · {siteConfig.title}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 60b9989..85e937f 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -10,6 +10,14 @@ export interface Post {
reading_time: number;
}
+export type ContactKind = 'email' | 'mastodon' | 'github' | 'bluesky' | 'instagram' | 'url';
+
+export interface ContactLink {
+ kind: ContactKind | string;
+ label: string;
+ value: string;
+}
+
export interface SiteConfig {
title: string;
subtitle: string;
@@ -19,6 +27,8 @@ export interface SiteConfig {
favicon: string;
theme: string;
custom_css: string;
+ contact_intro: string;
+ contact_links: ContactLink[];
}
export interface Asset {
diff --git a/frontend/src/pages/contact.astro b/frontend/src/pages/contact.astro
new file mode 100644
index 0000000..5051619
--- /dev/null
+++ b/frontend/src/pages/contact.astro
@@ -0,0 +1,116 @@
+---
+import Layout from '../layouts/Layout.astro';
+
+interface ContactLink {
+ kind: string;
+ label: string;
+ value: string;
+}
+
+interface SiteConfig {
+ contact_intro?: string;
+ contact_links?: ContactLink[];
+}
+
+const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
+
+let siteConfig: SiteConfig = {};
+let error = '';
+
+try {
+ const res = await fetch(`${API_URL}/api/config`);
+ if (res.ok) {
+ siteConfig = await res.json();
+ } else {
+ error = 'Failed to load contact details.';
+ }
+} catch (e) {
+ error = `Could not connect to backend: ${e instanceof Error ? e.message : String(e)}`;
+ console.error(error);
+}
+
+const links: ContactLink[] = siteConfig.contact_links ?? [];
+const intro = siteConfig.contact_intro ?? '';
+
+function hrefFor(link: ContactLink): string {
+ const v = link.value.trim();
+ if (link.kind === 'email') {
+ return v.startsWith('mailto:') ? v : `mailto:${v}`;
+ }
+ return v;
+}
+
+function isExternal(link: ContactLink): boolean {
+ return link.kind !== 'email';
+}
+
+const KIND_LABEL: Record
= {
+ email: 'Email',
+ mastodon: 'Mastodon',
+ bluesky: 'Bluesky',
+ github: 'GitHub',
+ instagram: 'Instagram',
+ url: 'Link',
+};
+---
+
+
+
+
+
Correspondence
+
+ Get in touch
+
+ {intro && (
+
+ {intro}
+
+ )}
+
+
+ {error && (
+
+ )}
+
+ {links.length === 0 && !error && (
+
+
+ No contact channels listed yet.
+
+
+ )}
+
+ {links.length > 0 && (
+
+ )}
+
+
+ ✦
+
+
+