c8388416
add askama templates and HTTP skeleton with repo list page
a73x 2026-03-30 18:59
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff --git a/askama.toml b/askama.toml new file mode 100644 index 0000000..c43baab --- /dev/null +++ b/askama.toml @@ -0,0 +1,2 @@ [general] dirs = ["src/server/http/templates"] diff --git a/src/server/http/mod.rs b/src/server/http/mod.rs new file mode 100644 index 0000000..e1e7eb2 --- /dev/null +++ b/src/server/http/mod.rs @@ -0,0 +1,18 @@ pub mod repo_list; use std::path::PathBuf; use std::sync::Arc; use axum::Router; #[derive(Debug, Clone)] pub struct AppState { pub repos_dir: PathBuf, pub site_title: String, } pub fn router(state: AppState) -> Router { let shared = Arc::new(state); Router::new() .route("/", axum::routing::get(repo_list::handler)) .with_state(shared) } diff --git a/src/server/http/repo_list.rs b/src/server/http/repo_list.rs new file mode 100644 index 0000000..cdc2fdc --- /dev/null +++ b/src/server/http/repo_list.rs @@ -0,0 +1,87 @@ use std::sync::Arc; use axum::extract::State; use axum::response::IntoResponse; use super::AppState; #[derive(Debug)] pub struct RepoListItem { pub name: String, pub description: String, pub last_commit: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "repo_list.html")] pub struct RepoListTemplate { pub site_title: String, pub repos: Vec<RepoListItem>, } pub async fn handler(State(state): State<Arc<AppState>>) -> impl IntoResponse { let repos = build_repo_list(&state); RepoListTemplate { site_title: state.site_title.clone(), repos, } } fn build_repo_list(state: &AppState) -> Vec<RepoListItem> { let entries = match crate::repos::discover(&state.repos_dir) { Ok(e) => e, Err(_) => return Vec::new(), }; entries .into_iter() .map(|entry| { let description = read_description(&entry); let last_commit = read_last_commit(&entry); RepoListItem { name: entry.name, description, last_commit, } }) .collect() } fn read_description(entry: &crate::repos::RepoEntry) -> String { let desc_path = if entry.bare { entry.path.join("description") } else { entry.path.join(".git").join("description") }; std::fs::read_to_string(&desc_path) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty() && !s.starts_with("Unnamed repository")) .unwrap_or_default() } fn read_last_commit(entry: &crate::repos::RepoEntry) -> String { let repo = match crate::repos::open(entry) { Ok(r) => r, Err(_) => return String::new(), }; let head = match repo.head() { Ok(h) => h, Err(_) => return String::new(), }; let commit = match head.peel_to_commit() { Ok(c) => c, Err(_) => return String::new(), }; let time = commit.time(); let secs = time.seconds(); // Format as YYYY-MM-DD using chrono use chrono::{TimeZone, Utc}; Utc.timestamp_opt(secs, 0) .single() .map(|dt| dt.format("%Y-%m-%d").to_string()) .unwrap_or_default() } diff --git a/src/server/http/templates/base.html b/src/server/http/templates/base.html new file mode 100644 index 0000000..4d27da3 --- /dev/null +++ b/src/server/http/templates/base.html @@ -0,0 +1,172 @@ <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>{% block title %}{{ site_title }}{% endblock %}</title> <style> :root { --bg: #0d1117; --bg-secondary: #161b22; --bg-tertiary: #21262d; --border: #30363d; --text: #e6edf3; --text-muted: #8b949e; --text-link: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d29922; } *, *::before, *::after { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; } a { color: var(--text-link); text-decoration: none; } a:hover { text-decoration: underline; } /* Header */ .header { background: var(--bg-secondary); border-bottom: 1px solid var(--border); padding: 0 24px; display: flex; align-items: center; justify-content: space-between; height: 48px; } .header a { color: var(--text); font-weight: 600; } /* Layout */ .layout { display: flex; min-height: calc(100vh - 48px - 36px); } /* Sidebar */ .sidebar { width: 180px; background: var(--bg-secondary); border-right: 1px solid var(--border); padding: 16px 0; flex-shrink: 0; } .sidebar a { display: block; padding: 6px 16px; color: var(--text); } .sidebar a:hover { background: var(--bg-tertiary); text-decoration: none; } .sidebar a.active { background: var(--bg-tertiary); border-left: 2px solid var(--text-link); color: var(--text-link); padding-left: 14px; } .sidebar .divider { height: 1px; background: var(--border); margin: 8px 16px; } .sidebar .badge { display: inline-block; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 20px; padding: 0 6px; font-size: 11px; color: var(--text-muted); float: right; } /* Content */ .content { flex: 1; padding: 24px; overflow: auto; } /* Footer */ .footer { background: var(--bg-secondary); border-top: 1px solid var(--border); padding: 0 24px; height: 36px; display: flex; align-items: center; color: var(--text-muted); font-size: 12px; } /* Status badges */ .status-open { color: var(--green); } .status-closed { color: var(--red); } .status-merged { color: var(--yellow); } /* Diff */ .diff-add { background: rgba(63,185,80,0.1); color: var(--green); } .diff-del { background: rgba(248,81,73,0.1); color: var(--red); } .diff-hunk { color: var(--text-muted); } .diff-file { font-weight: 600; border-top: 1px solid var(--border); padding: 8px 0 4px; } /* Mono */ .mono, pre, code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 12px; } pre { background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px; overflow-x: auto; } code { background: var(--bg-tertiary); border-radius: 3px; padding: 0 4px; } pre code { background: none; padding: 0; } /* Tables */ table { width: 100%; border-collapse: collapse; } th { text-align: left; color: var(--text-muted); font-weight: 400; border-bottom: 1px solid var(--border); padding: 8px 12px; } td { padding: 10px 12px; border-bottom: 1px solid var(--border); } tr:last-child td { border-bottom: none; } tr:hover td { background: var(--bg-secondary); } </style> </head> <body> <div class="header"> <a href="/">{{ site_title }}</a> <div>{% block header_right %}{% endblock %}</div> </div> <div class="layout"> <div class="content"> {% block body %}{% endblock %} </div> </div> <div class="footer">git-collab-server</div> </body> </html> diff --git a/src/server/http/templates/error.html b/src/server/http/templates/error.html new file mode 100644 index 0000000..7e55631 --- /dev/null +++ b/src/server/http/templates/error.html @@ -0,0 +1,10 @@ {% extends "base.html" %} {% block title %}{{ status_code }} — {{ site_title }}{% endblock %} {% block body %} <div style="max-width: 800px; margin: 80px auto; text-align: center;"> <div style="font-size: 72px; font-weight: 700; color: var(--text-muted); line-height: 1;">{{ status_code }}</div> <p style="color: var(--text-muted); margin-top: 16px; font-size: 16px;">{{ message }}</p> </div> {% endblock %} diff --git a/src/server/http/templates/repo_list.html b/src/server/http/templates/repo_list.html new file mode 100644 index 0000000..fef031d --- /dev/null +++ b/src/server/http/templates/repo_list.html @@ -0,0 +1,29 @@ {% extends "base.html" %} {% block body %} <div style="max-width: 800px; margin: 0 auto;"> <h2 style="margin-top: 0;">Repositories</h2> {% if repos.is_empty() %} <p style="color: var(--text-muted);">No repositories found.</p> {% else %} <table> <thead> <tr> <th>Name</th> <th>Description</th> <th>Last Commit</th> </tr> </thead> <tbody> {% for repo in repos %} <tr> <td><a href="/{{ repo.name }}">{{ repo.name }}</a></td> <td style="color: var(--text-muted);">{{ repo.description }}</td> <td class="mono" style="color: var(--text-muted);">{{ repo.last_commit }}</td> </tr> {% endfor %} </tbody> </table> {% endif %} </div> {% endblock %} diff --git a/src/server/main.rs b/src/server/main.rs index 46b49aa..dfe6648 100644 --- a/src/server/main.rs +++ b/src/server/main.rs @@ -1,4 +1,5 @@ mod config; mod http; mod repos; mod ssh;