yuki / okibi
焚き火の仲間だけの自前Gitホスティング(このサイト自身)
1614a4047481d0c24e3153142204dc96d722ed75
yuki
1781058565
web: 機能紹介ページ /about + トップにヒーロー&機能グリッド
---
src/main.rs | 1 +
src/style.css | 26 +++++++++++++++++++
src/web.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 106 insertions(+), 2 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index f1a604e..d529c6b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -42,6 +42,7 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new()
.route("/", get(web::index))
+ .route("/about", get(web::about))
.route("/healthz", get(healthz))
.route("/login", get(web::login_form).post(web::login_submit))
.route("/auth/verify", get(web::verify))
diff --git a/src/style.css b/src/style.css
index 791d56f..40163ea 100644
--- a/src/style.css
+++ b/src/style.css
@@ -62,3 +62,29 @@ footer {
border-top: 1px solid var(--line); color: var(--muted); font-size: 12px;
text-align: center; padding: 24px;
}
+.hero { text-align: center; padding: 32px 0 20px; }
+.hero .flame { font-size: 52px; filter: drop-shadow(0 0 24px rgba(226,88,34,.6)); }
+.htitle {
+ font-size: 44px; margin: 8px 0 4px; letter-spacing: 1px;
+ background: linear-gradient(90deg, var(--fire2), var(--fire));
+ -webkit-background-clip: text; background-clip: text; color: transparent;
+}
+.htag { color: var(--muted); font-size: 15px; margin: 0 0 20px; }
+.hcta { display: flex; gap: 12px; justify-content: center; }
+.btn {
+ display: inline-block; background: var(--fire); color: #fff !important;
+ padding: 11px 22px; border-radius: 10px; font-weight: 600; text-decoration: none;
+}
+.btn:hover { background: var(--fire2); text-decoration: none; }
+.btn.ghost { background: transparent; border: 1px solid var(--line); color: var(--link) !important; }
+.features {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
+ gap: 14px; margin: 18px 0 28px;
+}
+.feature {
+ background: var(--panel); border: 1px solid var(--line);
+ border-radius: 12px; padding: 18px;
+}
+.feature .ficon { font-size: 26px; }
+.feature h3 { margin: 8px 0 6px; font-size: 16px; }
+.feature p { font-size: 13px; margin: 0; }
diff --git a/src/web.rs b/src/web.rs
index 2363703..8591aa6 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -22,6 +22,7 @@ fn layout(title: &str, user: &Option<User>, body: Markup) -> Markup {
header.top {
a.brand href="/" { "🔥 Okibi" }
nav {
+ a href="/about" { "機能" }
@if let Some(u) = user {
a href="/new" { "+ 新規リポジトリ" }
a href={"/" (u.handle)} { "@" (u.handle) }
@@ -46,14 +47,66 @@ async fn current(state: &AppState, headers: &HeaderMap) -> Option<User> {
auth::current_user(&state.db, headers).await
}
+/// Feature list — single source of truth for the about page & home hero.
+fn features() -> [(&'static str, &'static str, &'static str); 8] {
+ [
+ ("🔥", "焚き火だけ", "atsm の仲間だけがログイン。magic-link と管理者の招待リンクで、メールサーバ無しでも入れる。"),
+ ("📦", "リポジトリ", "公開/非公開を選んで作成。git push / pull は HTTPS + アクセストークンで。"),
+ ("👀", "コード閲覧", "Web でファイルツリーを辿り、ファイルの中身まで読める。ログイン不要で公開リポを覗ける。"),
+ ("📥", "取り込み", "GitHub 等の URL を渡すと、Okibi マシンが直接 mirror clone。巨大リポも自宅回線を通さず取り込む。"),
+ ("⚙️", "簡易CI", "リポジトリに .okibi/ci.sh を置けば push 時に実行。テストやビルドを自動で。"),
+ ("🔑", "トークン", "用途ごとに PAT を発行。パスワード代わりに push、いつでも作り直せる。"),
+ ("👥", "メンバー", "管理者が allowlist を管理し、招待リンクを発行。焚き火の輪を広げる。"),
+ ("🛡", "安全より", "秘密はハッシュで保存。git 処理は実 git に委譲し、自前で pack を再実装しない。"),
+ ]
+}
+
+fn feature_grid() -> Markup {
+ html! {
+ div.features {
+ @for (icon, title, desc) in features() {
+ div.feature {
+ div.ficon { (icon) }
+ h3 { (title) }
+ p.muted { (desc) }
+ }
+ }
+ }
+ }
+}
+
+fn hero() -> Markup {
+ html! {
+ section.hero {
+ div.flame { "🔥" }
+ h1.htitle { "Okibi" }
+ p.htag { "焚き火の仲間だけの、自前 Git ホスティング。" br;
+ "おき火=燃え続ける熾火。コードが、残る場所。" }
+ div.hcta {
+ a.btn href="/about" { "機能を見る" }
+ a.btn.ghost href="/login" { "ログイン" }
+ }
+ }
+ }
+}
+
// GET /
pub async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
let user = current(&state, &headers).await;
let repos = repos::list_visible(&state.db, user.as_ref()).await;
let body = html! {
- h1 { "リポジトリ" }
+ @if user.is_none() {
+ (hero())
+ (feature_grid())
+ h2 { "公開リポジトリ" }
+ } @else {
+ h1 { "リポジトリ" }
+ }
@if repos.is_empty() {
- p.muted { "まだ何もない。" a href="/new" { "最初の一本を建てる" } "。" }
+ p.muted {
+ @if user.is_some() { "まだ何もない。" a href="/new" { "最初の一本を建てる" } "。" }
+ @else { "まだ公開リポジトリはありません。" }
+ }
}
ul.repolist {
@for (r, owner) in &repos {
@@ -68,6 +121,30 @@ pub async fn index(State(state): State<AppState>, headers: HeaderMap) -> Respons
Html(layout("home", &user, body).into_string()).into_response()
}
+// GET /about
+pub async fn about(State(state): State<AppState>, headers: HeaderMap) -> Response {
+ let user = current(&state, &headers).await;
+ let body = html! {
+ (hero())
+ h2 { "できること" }
+ (feature_grid())
+ section.card {
+ h2 { "使いはじめる" }
+ ol {
+ li { "焚き火のメンバーに招待リンクをもらう(または管理者に依頼)" }
+ li { "ログインしたら " code { "設定" } " で PAT を発行" }
+ li { code { "git clone https://<handle>:<PAT>@okibi.fly.dev/<handle>/<repo>.git" } }
+ li { "push すれば " code { ".okibi/ci.sh" } " が回る(CI 有効時)" }
+ }
+ }
+ section.card {
+ h2 { "技術" }
+ p.muted { "Rust + axum + SQLite + maud。git smart-HTTP は実 git-http-backend に委譲。" }
+ }
+ };
+ Html(layout("機能", &user, body).into_string()).into_response()
+}
+
// GET /{owner}
pub async fn user_view(
State(state): State<AppState>,