a73x

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;