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
+216
View File
@@ -904,3 +904,219 @@ pub async fn get_admin_stats(pool: &PgPool) -> Result<AdminStats, sqlx::Error> {
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
.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<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
async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {