mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 14:26:37 -06:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user