oxipage-ext-profile 0.2.0

프로필 확장 — 소개 명함 / 포트폴리오 싱글턴 (doc/02 §2.3)
pub mod model;
pub mod repo;
pub mod routes;

use async_trait::async_trait;
use axum::Router;
use axum::routing::get;
use oxipage_core::builder::{BuildExt, SearchDoc, StaticPage};
use oxipage_core::extension::{Extension, Lang, LobbyCard, Migration};
use oxipage_core::state::AppState;
use sqlx::SqlitePool;
use std::error::Error;

pub struct ProfileExtension;

#[async_trait]
impl Extension for ProfileExtension {
    fn id(&self) -> &'static str {
        "profile"
    }
    fn table_names(&self) -> Vec<&'static str> {
        vec!["profile"]
    }

    fn display_name(&self, lang: Lang) -> String {
        match lang {
            Lang::Ko => "프로필".to_string(),
            Lang::En => "Profile".to_string(),
        }
    }

    fn migrations(&self) -> Vec<Migration> {
        vec![Migration {
            version: 1,
            name: "init",
            sql: include_str!("../migrations/0001_init.sql"),
        }]
    }

    fn routes(&self) -> Router<AppState> {
        Router::new().route("/", get(routes::get_profile).put(routes::put_profile))
    }

    async fn lobby_summary(&self, _ctx: &AppState) -> Option<LobbyCard> {
        None
    }

    async fn on_startup(&self, ctx: &AppState) -> anyhow::Result<()> {
        repo::ensure_singleton(&ctx.db, &ctx.config.site.name).await
    }
}

impl BuildExt for ProfileExtension {
    fn ext_id(&self) -> &'static str {
        "profile"
    }

    fn build_pages(&self, db: &SqlitePool) -> Result<Vec<StaticPage>, Box<dyn Error + Send + Sync>> {
        let handle = tokio::runtime::Handle::current();
        let profile: Option<model::Profile> = handle.block_on(repo::get(db))?;

        let Some(profile) = profile else {
            return Ok(vec![]);
        };

        let html = format!(
            r#"<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{name}</title>
  <meta property="og:title" content="{name}">
  <meta property="og:type" content="profile">
  <meta property="og:url" content="/profile/">
  <link rel="canonical" href="/profile/">
</head>
<body>
  <div id="root"></div>
  <script src="/assets/index.js"></script>
</body>
</html>
"#,
            name = profile.display_name
        );

        Ok(vec![StaticPage {
            path: "profile/index.html".to_string(),
            content: html,
        }])
    }

    fn build_data(&self, db: &SqlitePool) -> Result<Box<dyn erased_serde::Serialize + Send>, Box<dyn Error + Send + Sync>> {
        let handle = tokio::runtime::Handle::current();
        let profile: Option<model::Profile> = handle.block_on(repo::get(db))?;
        Ok(Box::new(profile))
    }

    fn build_search_docs(&self, _db: &SqlitePool) -> Result<Vec<SearchDoc>, Box<dyn Error + Send + Sync>> {
        Ok(vec![])
    }
}