commits de669bc crates/git-ents-server/src/web.rs
@@ -1,5 +1,6 @@
-//! Browser-facing HTML: a small GitHub-style web UI rendered server-side with
-//! Maud and styled with Primer CSS (GitHub's own design system). The git
+//! Browser-facing HTML: a small, hand-styled web UI rendered server-side with
+//! Maud. The look mirrors <https://jdc.pub>: DM Sans / Lora / IBM Plex Mono on a
+//! warm-gold palette that follows the system light/dark preference. The git
//! smart-HTTP gateway in [`crate::http`] delegates plain browser GETs here.
use std::path::{Path, PathBuf};
@@ -7,7 +8,7 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
-use maud::{DOCTYPE, Markup, html};
+use maud::{DOCTYPE, Markup, PreEscaped, html};
use tokio::process::Command;
use crate::AppState;
@@ -16,8 +17,143 @@
/// Greatest repository nesting depth served: `repo`, `org/repo`, `org/team/repo`.
const MAX_DEPTH: usize = 3;
-/// Pinned Primer CSS so the look does not drift with upstream releases.
-const PRIMER_CSS: &str = "https://unpkg.com/@primer/css@21.5.0/dist/primer.css";
+/// Web fonts, matching the typography of <https://jdc.pub>.
+const FONTS: &str = "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Lora:wght@500;600;700&display=swap";
+
+/// Hand-written stylesheet (no external CSS framework) so the look stays stable
+/// and self-contained. Colors, type, and radii track <https://jdc.pub>, with a
+/// `prefers-color-scheme` block for automatic dark mode.
+const STYLE: &str = r#"
+:root {
+ --font-sans: "DM Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-serif: "Lora", Georgia, "Times New Roman", serif;
+ --font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace;
+ --max-width: 52rem;
+ --color-bg: #faf8f4;
+ --color-surface: #fff;
+ --color-text: #2a2518;
+ --color-text-muted: #8a7e6a;
+ --color-link: #b07d10;
+ --color-link-hover: #96690a;
+ --color-border: #ede9de;
+ --color-code-bg: #f5f3eb;
+ --color-accent: #b07d10;
+ --color-accent-subtle: #b07d100f;
+ --shadow-sm: 0 1px 3px #0000000d;
+ --shadow-md: 0 4px 16px #0000000f;
+ --radius-sm: 10px;
+ --radius-pill: 100px;
+ --glow: #b07d1012;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-bg: #171510;
+ --color-surface: #211f17;
+ --color-text: #ede8d8;
+ --color-text-muted: #a89e88;
+ --color-link: #d4a030;
+ --color-link-hover: #e4b850;
+ --color-border: #383324;
+ --color-code-bg: #211f17;
+ --color-accent: #d4a030;
+ --color-accent-subtle: #d4a03012;
+ --shadow-sm: 0 1px 3px #00000040;
+ --shadow-md: 0 4px 16px #0000004d;
+ --glow: #d4a03014;
+ }
+}
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { font-size: 17px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
+body {
+ font-family: var(--font-sans);
+ background: var(--color-bg);
+ color: var(--color-text);
+ line-height: 1.7;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background-image: radial-gradient(80% 40% at 50% -10%, var(--glow) 0%, #0000 70%);
+}
+a { color: var(--color-link); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--color-link) 25%, transparent); text-underline-offset: 2px; transition: color .15s, text-decoration-color .15s; }
+a:hover { color: var(--color-link-hover); text-decoration-color: currentColor; }
+.icon { flex-shrink: 0; fill: currentColor; vertical-align: -0.125em; }
+
+.site-nav { position: sticky; top: 0; z-index: 100; background: color-mix(in srgb, var(--color-bg) 82%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--color-border); }
+.nav-inner { max-width: var(--max-width); margin: 0 auto; padding: 1rem 1.5rem; display: flex; align-items: center; gap: 2rem; }
+.nav-logo { font-family: var(--font-mono); font-weight: 700; font-size: 1.05rem; color: var(--color-text); letter-spacing: -.01em; text-decoration: none; margin-right: auto; transition: color .15s; }
+.nav-logo:hover { color: var(--color-accent); }
+
+.content { max-width: var(--max-width); width: 100%; margin: 0 auto; padding: 2.25rem 1.5rem 3rem; flex: 1; }
+
+.page-header { margin-bottom: 1.75rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
+.page-header::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; }
+.page-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 700; letter-spacing: -.01em; line-height: 1.3; display: inline-flex; align-items: center; gap: .55rem; }
+.page-title .icon { color: var(--color-accent); width: 20px; height: 20px; }
+.page-title a { color: inherit; text-decoration: none; }
+.page-title a:hover { color: var(--color-accent); }
+.count { margin-left: auto; font-family: var(--font-mono); font-size: .8rem; color: var(--color-text-muted); }
+
+.branch { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: .1rem .6rem; }
+
+.repo-list { list-style: none; }
+.repo-list li + li { border-top: 1px solid var(--color-border); }
+.repo-row { display: flex; align-items: center; gap: .85rem; padding: .9rem .75rem; border-radius: var(--radius-sm); text-decoration: none; color: inherit; transition: background .18s, transform .18s; }
+.repo-row:hover { text-decoration: none; transform: translateX(3px); }
+.repo-row:hover .repo-name { color: var(--color-accent); }
+.repo-row:hover .repo-arrow { opacity: 1; transform: translateX(0); }
+.repo-row .repo-icon { color: var(--color-accent); display: inline-flex; }
+.repo-name { font-family: var(--font-mono); font-size: 1rem; font-weight: 600; flex: 1; min-width: 0; transition: color .18s; word-break: break-all; }
+.repo-badge { font-family: var(--font-mono); font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .08rem .55rem; }
+.repo-arrow { color: var(--color-accent); display: inline-flex; opacity: 0; transform: translateX(-6px); transition: opacity .22s, transform .22s; }
+
+.card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); margin-bottom: 1.5rem; overflow: hidden; }
+.card-header { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); background: var(--color-code-bg); padding: .55rem 1.1rem; border-bottom: 1px solid var(--color-border); }
+.card-row { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; font-family: var(--font-mono); font-size: .9rem; }
+.card-row + .card-row { border-top: 1px solid var(--color-border); }
+.card-row .icon { color: var(--color-text-muted); }
+.card-row.is-dir .icon { color: var(--color-accent); }
+
+.commit { padding: .85rem 1.1rem; }
+.commit + .commit { border-top: 1px solid var(--color-border); }
+.commit-subject { font-weight: 600; line-height: 1.45; }
+.commit-meta { font-size: .8rem; color: var(--color-text-muted); margin-top: .15rem; }
+.commit-meta .sha { font-family: var(--font-mono); background: var(--color-code-bg); padding: .08rem .4rem; border-radius: 5px; font-size: .76rem; margin-right: .5rem; }
+
+.clone { display: flex; align-items: stretch; }
+.clone code { flex: 1; font-family: var(--font-mono); font-size: .82rem; background: var(--color-code-bg); padding: .7rem 1rem; overflow-x: auto; white-space: pre; color: var(--color-text); }
+.copy-btn { font-family: var(--font-mono); font-size: .74rem; font-weight: 600; border: none; border-left: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text-muted); padding: 0 1rem; cursor: pointer; transition: color .15s, background .15s; }
+.copy-btn:hover { color: var(--color-accent); background: var(--color-accent-subtle); }
+
+.blankslate { text-align: center; padding: 3rem 1.5rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); }
+.blankslate h2 { font-family: var(--font-serif); font-size: 1.3rem; font-weight: 700; margin-bottom: .5rem; }
+.blankslate p { color: var(--color-text-muted); }
+.blankslate code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .15rem .45rem; border-radius: 5px; font-size: .85rem; }
+.btn { display: inline-flex; align-items: center; gap: .4rem; margin-top: 1.25rem; font-size: .88rem; font-weight: 600; color: var(--color-accent); text-decoration: none; padding: .45rem 1rem; border-radius: var(--radius-sm); border: 1px solid var(--color-border); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: border-color .15s, box-shadow .15s; }
+.btn:hover { text-decoration: none; border-color: var(--color-accent); box-shadow: var(--shadow-md); }
+
+.site-footer { border-top: 1px solid var(--color-border); color: var(--color-text-muted); font-size: .8rem; margin-top: auto; }
+.footer-inner { max-width: var(--max-width); margin: 0 auto; padding: 2rem 1.5rem; text-align: center; }
+.footer-inner a { color: var(--color-text-muted); text-decoration: none; }
+.footer-inner a:hover { color: var(--color-accent); }
+
+@media (max-width: 640px) {
+ html { font-size: 16px; }
+ .content { padding: 1.5rem 1.25rem 2.5rem; }
+}
+"#;
+
+/// Clipboard handler for the clone-URL copy button.
+const COPY_SCRIPT: &str = r#"
+document.querySelectorAll('[data-copy]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ navigator.clipboard.writeText(btn.dataset.copy).then(() => {
+ const label = btn.textContent;
+ btn.textContent = 'Copied';
+ setTimeout(() => { btn.textContent = label; }, 1200);
+ });
+ });
+});
+"#;
/// Render the page for `path`: the repository index at the root, or a single
/// repository's overview otherwise. `host` is the request's `Host` header, used
@@ -47,22 +183,28 @@
page(
"Repositories",
html! {
- div."d-flex"."flex-items-center"."mb-3" {
- h2.f3.text-normal."flex-auto" { "Repositories" }
+ div.page-header {
+ h1.page-title { (icon_repo()) "Repositories" }
+ @if !repos.is_empty() {
+ span.count { (repos.len()) " repos" }
+ }
}
@if repos.is_empty() {
- div.blankslate."color-bg-subtle"."rounded-2" {
- h3.blankslate-heading { "No repositories yet" }
+ div.blankslate {
+ h2 { "No repositories yet" }
p { "Push to this server to create one:" }
p { code { "git push <url>/my-repo.git HEAD" } }
}
} @else {
- div.Box {
+ ul.repo-list {
@for repo in &repos {
- div.Box-row."d-flex"."flex-items-center" {
- span.mr-2 { "📦" }
- a.text-bold.flex-auto href={ "/" (repo) } { (repo) }
- span.Label."Label--secondary" { "git" }
+ li {
+ a.repo-row href={ "/" (repo) } {
+ span.repo-icon { (icon_repo()) }
+ span.repo-name { (repo) }
+ span.repo-badge { "git" }
+ span.repo-arrow { (icon_arrow()) }
+ }
}
}
}
@@ -85,44 +227,48 @@
page(
name,
html! {
- div."d-flex"."flex-items-center"."mb-3" {
- h2.f3.text-normal."flex-auto" {
- "📂 " a.text-bold href={ "/" (rel) } { (rel) }
- @if let Some(branch) = &branch {
- " " span.Label."Label--accent" { (branch) }
- }
+ div.page-header {
+ h1.page-title {
+ (icon_folder())
+ a href={ "/" (rel) } { (rel) }
+ }
+ @if let Some(branch) = &branch {
+ span.branch { (branch) }
}
}
- div.Box."mb-4" {
- div.Box-header { span.text-bold { "Clone" } }
- div.Box-body { code { "git clone " (clone_url) } }
+ div.card {
+ div.card-header { "Clone" }
+ div.clone {
+ code { "git clone " (clone_url) }
+ button.copy-btn data-copy={ "git clone " (clone_url) } { "Copy" }
+ }
}
@if commits.is_empty() {
- div.blankslate."color-bg-subtle"."rounded-2" {
- h3.blankslate-heading { "This repository is empty" }
+ div.blankslate {
+ h2 { "This repository is empty" }
p { "Push a commit to get started." }
}
} @else {
@if !tree.is_empty() {
- div.Box."mb-4" {
- div.Box-header { span.text-bold { "Files" } }
+ div.card {
+ div.card-header { "Files" }
@for entry in &tree {
- div.Box-row {
- span.mr-2 { (if entry.is_dir { "📁" } else { "📄" }) }
+ div.card-row.is-dir[entry.is_dir] {
+ @if entry.is_dir { (icon_folder()) } @else { (icon_file()) }
span { (entry.name) }
}
}
}
}
- div.Box {
- div.Box-header { span.text-bold { "Recent commits" } }
+ div.card {
+ div.card-header { "Recent commits" }
@for commit in &commits {
- div.Box-row {
- div.text-bold { (commit.subject) }
- div."color-fg-muted".f6 {
- code.mr-2 { (commit.short) }
+ div.commit {
+ div.commit-subject { (commit.subject) }
+ div.commit-meta {
+ span.sha { (commit.short) }
(commit.author) " · " (commit.when)
}
}
@@ -263,7 +409,7 @@
"Not found",
html! {
div.blankslate {
- h3.blankslate-heading { "404" }
+ h2 { "404" }
p { "No such repository." }
a.btn href="/" { "Back to repositories" }
}
@@ -272,7 +418,7 @@
)
}
-/// Wrap page `body` in the shared HTML shell, navigation, and Primer styling.
+/// Wrap page `body` in the shared HTML shell, navigation, and styling.
fn page(title: &str, body: Markup) -> Markup {
html! {
(DOCTYPE)
@@ -281,18 +427,58 @@
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (title) " · Git Ents" }
- link rel="stylesheet" href=(PRIMER_CSS);
+ link rel="preconnect" href="https://fonts.googleapis.com";
+ link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
+ link rel="stylesheet" href=(FONTS);
+ style { (PreEscaped(STYLE)) }
}
- body."color-bg-default"."color-fg-default" {
- header.Header."color-bg-inset" {
- div.Header-item {
- a.Header-link.f4.text-bold href="/" { "🌳 Git Ents" }
+ body {
+ nav.site-nav {
+ div.nav-inner {
+ a.nav-logo href="/" { "🌳 Git Ents" }
}
}
- div."container-lg"."p-responsive"."my-5" {
- (body)
+ main.content { (body) }
+ footer.site-footer {
+ div.footer-inner {
+ "Served by " a href="/" { "Git Ents" } "."
+ }
}
+ script { (PreEscaped(COPY_SCRIPT)) }
}
}
}
}
+
+/// Inline icons (16×16 Octicons paths), kept local so the UI has no asset deps.
+fn svg(path: &str) -> Markup {
+ html! {
+ svg.icon viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" {
+ (PreEscaped(format!("<path d=\"{path}\"/>")))
+ }
+ }
+}
+
+fn icon_repo() -> Markup {
+ svg(
+ "M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z",
+ )
+}
+
+fn icon_folder() -> Markup {
+ svg(
+ "M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z",
+ )
+}
+
+fn icon_file() -> Markup {
+ svg(
+ "M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z",
+ )
+}
+
+fn icon_arrow() -> Markup {
+ svg(
+ "M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z",
+ )
+}