mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -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,
|
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
|
// Admin stats - requires authentication
|
||||||
.route("/stats", axum::routing::get(get_admin_stats_handler))
|
.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)
|
// Icon API - proxy to SvelteKit (authentication handled by SvelteKit)
|
||||||
.route("/icons/{*path}", axum::routing::get(proxy_icons_handler))
|
.route("/icons/{*path}", axum::routing::get(proxy_icons_handler))
|
||||||
.fallback(api_404_and_method_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
|
// Tag API handlers
|
||||||
|
|
||||||
async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
|||||||
@@ -78,19 +78,14 @@ export interface AuthSession {
|
|||||||
expiresAt: string; // ISO 8601
|
expiresAt: string; // ISO 8601
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SocialPlatform =
|
|
||||||
| "github"
|
|
||||||
| "linkedin"
|
|
||||||
| "discord"
|
|
||||||
| "email"
|
|
||||||
| "pgp";
|
|
||||||
|
|
||||||
export interface SocialLink {
|
export interface SocialLink {
|
||||||
id: string;
|
id: string;
|
||||||
platform: SocialPlatform;
|
platform: string; // Not an enum for extensibility
|
||||||
label: string;
|
label: string;
|
||||||
value: string; // URL, username, or email address
|
value: string; // URL, username, or email address
|
||||||
|
icon: string; // Icon identifier (e.g., 'simple-icons:github')
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
displayOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SiteIdentity {
|
export interface SiteIdentity {
|
||||||
@@ -100,14 +95,7 @@ export interface SiteIdentity {
|
|||||||
siteTitle: string;
|
siteTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminPreferences {
|
|
||||||
sessionTimeoutMinutes: number;
|
|
||||||
eventsRetentionDays: number;
|
|
||||||
dashboardDefaultTab: "overview" | "events";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SiteSettings {
|
export interface SiteSettings {
|
||||||
identity: SiteIdentity;
|
identity: SiteIdentity;
|
||||||
socialLinks: SocialLink[];
|
socialLinks: SocialLink[];
|
||||||
adminPreferences: AdminPreferences;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-41
@@ -134,51 +134,17 @@ export async function getAdminStats(): Promise<AdminStats> {
|
|||||||
return clientApiFetch<AdminStats>("/api/stats");
|
return clientApiFetch<AdminStats>("/api/stats");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings API (currently mocked - no backend implementation yet)
|
// Settings API
|
||||||
export async function getSettings(): Promise<SiteSettings> {
|
export async function getSettings(): Promise<SiteSettings> {
|
||||||
// TODO: Implement when settings system is added
|
return clientApiFetch<SiteSettings>("/api/settings");
|
||||||
// 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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSettings(
|
export async function updateSettings(
|
||||||
settings: SiteSettings,
|
settings: SiteSettings,
|
||||||
): Promise<SiteSettings> {
|
): Promise<SiteSettings> {
|
||||||
// TODO: Implement when settings system is added
|
return clientApiFetch<SiteSettings>("/api/settings", {
|
||||||
return settings;
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import type { LayoutServerLoad } from "./$types";
|
import type { LayoutServerLoad } from "./$types";
|
||||||
import { getOGImageUrl } from "$lib/og-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 {
|
return {
|
||||||
|
settings,
|
||||||
metadata: {
|
metadata: {
|
||||||
title: "Xevion.dev",
|
title: settings.identity.siteTitle,
|
||||||
description:
|
description: settings.identity.bio.split("\n")[0], // First line of bio
|
||||||
"The personal website of Xevion, a full-stack software developer.",
|
|
||||||
ogImage: getOGImageUrl({ type: "index" }),
|
ogImage: getOGImageUrl({ type: "index" }),
|
||||||
url: url.toString(),
|
url: url.toString(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { apiFetch } from "$lib/api.server";
|
|||||||
import { renderIconSVG } from "$lib/server/icons";
|
import { renderIconSVG } from "$lib/server/icons";
|
||||||
import type { AdminProject } from "$lib/admin-types";
|
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 });
|
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
|
||||||
|
|
||||||
// Pre-render tag icons and clock icons (server-side only)
|
// 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(
|
const tagsWithIcons = await Promise.all(
|
||||||
project.tags.map(async (tag) => ({
|
project.tags.map(async (tag) => ({
|
||||||
...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 {
|
return {
|
||||||
projects: projectsWithIcons,
|
projects: projectsWithIcons,
|
||||||
|
socialLinksWithIcons,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+52
-26
@@ -3,15 +3,29 @@
|
|||||||
import ProjectCard from "$lib/components/ProjectCard.svelte";
|
import ProjectCard from "$lib/components/ProjectCard.svelte";
|
||||||
import PgpKeyModal from "$lib/components/PgpKeyModal.svelte";
|
import PgpKeyModal from "$lib/components/PgpKeyModal.svelte";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import IconSimpleIconsGithub from "~icons/simple-icons/github";
|
import type { SiteSettings } from "$lib/admin-types";
|
||||||
import IconSimpleIconsLinkedin from "~icons/simple-icons/linkedin";
|
|
||||||
import IconSimpleIconsDiscord from "~icons/simple-icons/discord";
|
|
||||||
import MaterialSymbolsMailRounded from "~icons/material-symbols/mail-rounded";
|
|
||||||
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
|
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const projects = data.projects;
|
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);
|
let pgpModalOpen = $state(false);
|
||||||
|
|
||||||
|
// Handle Discord click (copy to clipboard)
|
||||||
|
function handleDiscordClick(username: string) {
|
||||||
|
navigator.clipboard.writeText(username);
|
||||||
|
// TODO: Add toast notification
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AppWrapper class="overflow-x-hidden font-schibsted">
|
<AppWrapper class="overflow-x-hidden font-schibsted">
|
||||||
@@ -21,54 +35,66 @@
|
|||||||
>
|
>
|
||||||
<div class="flex flex-col pb-4">
|
<div class="flex flex-col pb-4">
|
||||||
<span class="text-2xl font-bold text-zinc-900 dark:text-white sm:text-3xl"
|
<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">
|
<span class="text-xl font-normal text-zinc-600 dark:text-zinc-400 sm:text-2xl">
|
||||||
Full-Stack Software Engineer
|
{settings.identity.occupation}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-4 text-zinc-700 dark:text-zinc-200">
|
<div class="py-4 text-zinc-700 dark:text-zinc-200">
|
||||||
<p class="sm:text-[0.95em]">
|
<p class="sm:text-[0.95em] whitespace-pre-line">
|
||||||
A fanatical software engineer with expertise and passion for sound,
|
{settings.identity.bio}
|
||||||
scalable and high-performance applications. I'm always working on
|
|
||||||
something new. <br />
|
|
||||||
Sometimes innovative — sometimes crazy.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<span class="text-zinc-700 dark:text-zinc-200">Connect with me</span>
|
<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">
|
<div class="flex flex-wrap gap-2 pl-3 pt-3 pb-2">
|
||||||
|
{#each visibleSocialLinks as link (link.id)}
|
||||||
|
{#if link.platform === "github" || link.platform === "linkedin"}
|
||||||
|
<!-- Simple link platforms -->
|
||||||
<a
|
<a
|
||||||
href="https://github.com/Xevion"
|
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"
|
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="size-4 text-zinc-600 dark:text-zinc-300">
|
||||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">GitHub</span>
|
{@html link.iconSvg}
|
||||||
</a>
|
</span>
|
||||||
<a
|
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
href="https://linkedin.com/in/ryancwalters"
|
>{link.label}</span
|
||||||
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>
|
</a>
|
||||||
|
{:else if link.platform === "discord"}
|
||||||
|
<!-- Discord - button that copies username -->
|
||||||
<button
|
<button
|
||||||
type="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"
|
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
|
||||||
>
|
>
|
||||||
<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>
|
</button>
|
||||||
|
{:else if link.platform === "email"}
|
||||||
|
<!-- Email - mailto link -->
|
||||||
<a
|
<a
|
||||||
href="mailto:your.email@example.com"
|
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"
|
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="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>
|
{@html link.iconSvg}
|
||||||
|
</span>
|
||||||
|
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
|
>{link.label}</span
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<!-- PGP Key - kept separate from settings system -->
|
||||||
<button
|
<button
|
||||||
type="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"
|
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 Button from "$lib/components/admin/Button.svelte";
|
||||||
import Input from "$lib/components/admin/Input.svelte";
|
import Input from "$lib/components/admin/Input.svelte";
|
||||||
import { getSettings, updateSettings } from "$lib/api";
|
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 { 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 settings = $state<SiteSettings | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -22,14 +17,18 @@
|
|||||||
let activeTab = $derived.by(() => {
|
let activeTab = $derived.by(() => {
|
||||||
const params = $page.params as { tab?: string };
|
const params = $page.params as { tab?: string };
|
||||||
const tab = params.tab as Tab | undefined;
|
const tab = params.tab as Tab | undefined;
|
||||||
return tab && ["identity", "social", "admin"].includes(tab)
|
return tab && ["identity", "social"].includes(tab) ? tab : "identity";
|
||||||
? tab
|
|
||||||
: "identity";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form state - will be populated when settings load
|
// Form state - will be populated when settings load
|
||||||
let formData = $state<SiteSettings | null>(null);
|
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() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const data = await getSettings();
|
const data = await getSettings();
|
||||||
@@ -47,7 +46,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!formData) return;
|
if (!formData || !hasChanges) return;
|
||||||
|
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
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) {
|
function navigateToTab(tab: Tab) {
|
||||||
goto(`/admin/settings/${tab}`, { replaceState: true });
|
goto(`/admin/settings/${tab}`, { replaceState: true });
|
||||||
}
|
}
|
||||||
@@ -113,12 +82,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold text-admin-text">Settings</h1>
|
<h1 class="text-xl font-semibold text-admin-text">Settings</h1>
|
||||||
<p class="mt-1 text-sm text-admin-text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#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}
|
{:else if formData}
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="border-b border-admin-border">
|
<div class="border-b border-admin-border">
|
||||||
@@ -147,18 +118,6 @@
|
|||||||
>
|
>
|
||||||
Social Links
|
Social Links
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,7 +150,7 @@
|
|||||||
bind:value={formData.identity.bio}
|
bind:value={formData.identity.bio}
|
||||||
placeholder="A brief description about yourself..."
|
placeholder="A brief description about yourself..."
|
||||||
rows={6}
|
rows={6}
|
||||||
help="Supports Markdown (rendered on the index page)"
|
help="Plain text for now (Markdown support coming later)"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Site Title"
|
label="Site Title"
|
||||||
@@ -204,28 +163,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if activeTab === "social"}
|
{:else if activeTab === "social"}
|
||||||
<div class="space-y-4">
|
<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">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each formData.socialLinks as link (link.id)}
|
{#each formData.socialLinks as link (link.id)}
|
||||||
{@const Icon = getSocialIcon(link.platform)}
|
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-admin-border bg-admin-surface-hover/50 p-4 hover:border-admin-border-hover transition-colors"
|
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="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-1 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm font-medium text-admin-text"
|
<div class="flex items-center gap-3">
|
||||||
>{link.label}</span
|
<Input
|
||||||
|
label="Label"
|
||||||
|
type="text"
|
||||||
|
bind:value={link.label}
|
||||||
|
placeholder="GitHub"
|
||||||
|
class="w-32"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="flex items-center gap-2 cursor-pointer pt-6"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-admin-text-muted"
|
||||||
|
>Visible</span
|
||||||
>
|
>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<span class="text-xs text-admin-text-muted">Visible</span>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={link.visible}
|
bind:checked={link.visible}
|
||||||
@@ -233,10 +200,44 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<Input
|
<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"}
|
type={link.platform === "email" ? "email" : "text"}
|
||||||
bind:value={link.value}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,45 +245,19 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<Button variant="secondary" onclick={handleCancel} disabled={saving}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={handleCancel}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onclick={handleSave} disabled={saving}>
|
<Button variant="primary" onclick={handleSave} disabled={!hasChanges || saving}>
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user