mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 00:26:31 -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:
+61
@@ -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"
|
||||
}
|
||||
+76
@@ -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"
|
||||
}
|
||||
+68
@@ -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"
|
||||
}
|
||||
+56
@@ -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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+7
-41
@@ -134,51 +134,17 @@ export async function getAdminStats(): Promise<AdminStats> {
|
||||
return clientApiFetch<AdminStats>("/api/stats");
|
||||
}
|
||||
|
||||
// Settings API (currently mocked - no backend implementation yet)
|
||||
// Settings API
|
||||
export async function getSettings(): Promise<SiteSettings> {
|
||||
// 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<SiteSettings>("/api/settings");
|
||||
}
|
||||
|
||||
export async function updateSettings(
|
||||
settings: SiteSettings,
|
||||
): Promise<SiteSettings> {
|
||||
// TODO: Implement when settings system is added
|
||||
return settings;
|
||||
return clientApiFetch<SiteSettings>("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<SiteSettings>("/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(),
|
||||
},
|
||||
|
||||
@@ -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<AdminProject[]>("/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,
|
||||
};
|
||||
};
|
||||
|
||||
+66
-40
@@ -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
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppWrapper class="overflow-x-hidden font-schibsted">
|
||||
@@ -21,54 +35,66 @@
|
||||
>
|
||||
<div class="flex flex-col pb-4">
|
||||
<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">
|
||||
Full-Stack Software Engineer
|
||||
{settings.identity.occupation}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="py-4 text-zinc-700 dark:text-zinc-200">
|
||||
<p class="sm:text-[0.95em]">
|
||||
A fanatical software engineer with expertise and passion for sound,
|
||||
scalable and high-performance applications. I'm always working on
|
||||
something new. <br />
|
||||
Sometimes innovative — sometimes crazy.
|
||||
<p class="sm:text-[0.95em] whitespace-pre-line">
|
||||
{settings.identity.bio}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="py-3">
|
||||
<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">
|
||||
<a
|
||||
href="https://github.com/Xevion"
|
||||
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"
|
||||
>
|
||||
<IconSimpleIconsGithub class="size-4 text-zinc-600 dark:text-zinc-300" />
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://linkedin.com/in/ryancwalters"
|
||||
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"
|
||||
>
|
||||
<IconSimpleIconsLinkedin class="size-4 text-zinc-600 dark:text-zinc-300" />
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">LinkedIn</span
|
||||
>
|
||||
</a>
|
||||
<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"
|
||||
>
|
||||
<IconSimpleIconsDiscord class="size-4 text-zinc-600 dark:text-zinc-300" />
|
||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">Discord</span>
|
||||
</button>
|
||||
<a
|
||||
href="mailto:your.email@example.com"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
{#each visibleSocialLinks as link (link.id)}
|
||||
{#if link.platform === "github" || link.platform === "linkedin"}
|
||||
<!-- Simple link platforms -->
|
||||
<a
|
||||
href={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 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>
|
||||
{:else if link.platform === "discord"}
|
||||
<!-- Discord - button that copies username -->
|
||||
<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"
|
||||
onclick={() => handleDiscordClick(link.value)}
|
||||
>
|
||||
<span class="size-4 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
|
||||
>
|
||||
</button>
|
||||
{: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
|
||||
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"
|
||||
|
||||
@@ -4,15 +4,10 @@
|
||||
import Button from "$lib/components/admin/Button.svelte";
|
||||
import Input from "$lib/components/admin/Input.svelte";
|
||||
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 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 loading = $state(true);
|
||||
@@ -22,14 +17,18 @@
|
||||
let activeTab = $derived.by(() => {
|
||||
const params = $page.params as { tab?: string };
|
||||
const tab = params.tab as Tab | undefined;
|
||||
return tab && ["identity", "social", "admin"].includes(tab)
|
||||
? tab
|
||||
: "identity";
|
||||
return tab && ["identity", "social"].includes(tab) ? tab : "identity";
|
||||
});
|
||||
|
||||
// Form state - will be populated when settings load
|
||||
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() {
|
||||
try {
|
||||
const data = await getSettings();
|
||||
@@ -47,7 +46,7 @@
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData) return;
|
||||
if (!formData || !hasChanges) return;
|
||||
|
||||
saving = true;
|
||||
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) {
|
||||
goto(`/admin/settings/${tab}`, { replaceState: true });
|
||||
}
|
||||
@@ -113,12 +82,14 @@
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-admin-text">Settings</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-admin-border">
|
||||
@@ -147,18 +118,6 @@
|
||||
>
|
||||
Social Links
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
<Input
|
||||
label="Site Title"
|
||||
@@ -204,39 +163,81 @@
|
||||
</div>
|
||||
{:else if activeTab === "social"}
|
||||
<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">
|
||||
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>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each formData.socialLinks as link (link.id)}
|
||||
{@const Icon = getSocialIcon(link.platform)}
|
||||
<div
|
||||
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="mt-2">
|
||||
<Icon class="w-5 h-5 text-admin-text-muted" />
|
||||
</div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-admin-text"
|
||||
>{link.label}</span
|
||||
>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<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"
|
||||
<div class="flex items-center gap-3">
|
||||
<Input
|
||||
label="Label"
|
||||
type="text"
|
||||
bind:value={link.label}
|
||||
placeholder="GitHub"
|
||||
class="w-32"
|
||||
/>
|
||||
</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>
|
||||
<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"}
|
||||
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>
|
||||
@@ -244,45 +245,19 @@
|
||||
{/each}
|
||||
</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}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={saving}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={handleCancel}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handleSave} disabled={saving}>
|
||||
<Button variant="primary" onclick={handleSave} disabled={!hasChanges || saving}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user