clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Show repos as cards on shelves #1

cat description

The index and owner pages as shelves by category, with chips to filter and sort. Work from the parallel landing-page session, plus a scenario and a class rename.

commits1oldest firstcombined diff 10 files, +172 -23
discussion0 comments

No comments yet: forge comment 1 adds one.

1/1 Show repos as cards on shelves on the index and owner pages

The index and each owner's page grouped nothing: one table, newest
first. Repos now sit on shelves by category, as cards with a mark, the
description, sites, open reviews and the last push. A repo's category
is one line in its bare repo's category file, next to description; a
repo without one goes on the shelf for Jev's topic, and failing that on
"other". Fuller shelves come first and "other" last.

Chips above the shelves jump to one; with JavaScript they show that
shelf alone, only repos with a site, or sort by name, and j and k walk
the cards. Backups carry the category file with the rest of a repo.

Written in a parallel session; this adds the shelves scenario, and
renames the card's mark from .mono, which already means monospace.
10 files changed, +172 -23
MREADME.md7++++
Mlib/forge.ts29++++++++++++++++
Mlib/repo.ts3+-
Mlib/types.ts3++
Msite/_includes/macros.njk54++++++++++++++++++++++++++++++
Msite/assets/keys.ts5++-
Msite/assets/ui.ts38+++++++++++++++++++++
Msite/index.njk11+------
Msite/owner.njk11+------
Atest/scenarios/shelves.sh34+++++++++++++++++++
M README.md +7 -0
M lib/forge.ts +29 -0
@@ -450,6 +450,20 @@ const decode = (s: string): string =>
450450 451451 const MODES: Record<string, string> = { '040000': 'drwxr-xr-x', '100644': '-rw-r--r--', '100755': '-rwxr-xr-x', '120000': 'lrwxrwxrwx', '160000': 'm---------' };452452 453+// Shelves for Jev's topics (repoTopic in lib/jev-commits.ts), for repos454+// whose owner hasn't given a category.455+const SHELVES: Record<string, string> = {456+ 'web app': 'web apps',457+ cli: 'terminal',458+ service: 'services',459+ library: 'libraries',460+ game: 'games',461+ 'static site': 'sites',462+ docs: 'writing',463+ experiment: 'experiments',464+ config: 'config',465+};466+453467 // Filters that return markup; setup() in render-page.ts marks their output safe.454468 export const markupFilters = new Set(['ago', 'date']);455469
@@ -514,6 +528,21 @@ export const filters = {
514528 return html.ci(status, { long });515529 },516530 date: html.date,531+ // The index and owner pages: repos on shelves, newest first. A shelf is532+ // the category the repo's owner gave it, else Jev's topic, else "other".533+ // Fuller shelves come first, "other" last.534+ shelves(repos: RepoSummary[]): { id: string; title: string; repos: RepoSummary[] }[] {535+ const by = new Map<string, RepoSummary[]>();536+ for (const r of [...repos].sort((a, b) => b.lastCommit.localeCompare(a.lastCommit))) {537+ const k = r.category || (r.topic && SHELVES[r.topic]) || 'other';538+ by.set(k, [...(by.get(k) ?? []), r]);539+ }540+ return [...by.keys()]541+ .sort((a, b) => Number(a === 'other') - Number(b === 'other') || by.get(b)!.length - by.get(a)!.length || a.localeCompare(b))542+ .map((k) => ({ id: k.replace(/[^a-z0-9]+/g, '-'), title: k === 'other' && by.size === 1 ? 'repositories' : k, repos: by.get(k)! }));543+ },544+ // A repo card's color: a hue from its name, the same on every build.545+ hue: (name: string): number => [...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7),517546 short: (sha: string | null | undefined): string => String(sha ?? '').slice(0, 7),518547 // Unix seconds (review logs) or an ISO date.519548 ago(ts: number | string | null): string {
M lib/repo.ts +2 -1
M lib/types.ts +3 -0
M site/_includes/macros.njk +54 -0
@@ -113,6 +113,60 @@ a.rev { font-size: 0.85em; color: var(--muted); border: 1px solid var(--border);
113113 </table>114114 {% endmacro %}115115 116+{# The index and owner pages: repos as cards on shelves by kind (the shelves117+ filter), with chips that jump to a shelf. ui.ts makes the chips filters and118+ adds "has a site" and the sort; keys.ts walks the cards with j and k. #}119+{% macro shelves(repos, owners = false) %}120+{% css %}121+.shelf-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 4px; }122+.shelf-bar .grow { flex: 1; }123+.chip { font: inherit; font-size: 0.85em; color: var(--muted); background: none; border: 1px solid var(--border); border-radius: 999px; padding: 2px 11px; cursor: pointer; white-space: nowrap; }124+.chip:hover { color: var(--fg); background: var(--hover); text-decoration: none; }125+.chip[aria-pressed="true"] { color: var(--fg); background: var(--hover); border-color: var(--faint); }126+.chip .n { color: var(--faint); margin-left: 4px; font-variant-numeric: tabular-nums; }127+.shelf-head { display: flex; align-items: baseline; gap: 8px; margin: 22px 0 10px; scroll-margin-top: 12px; }128+.shelf-head h2 { margin: 0; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }129+.shelf-head .n { color: var(--faint); font-size: 0.78rem; }130+.shelf-head::after { content: ''; flex: 1; border-top: 1px solid var(--border); transform: translateY(-3px); }131+.cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 10px; }132+.card { position: relative; display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--subtle); }133+.card:hover, .card:focus-within, .card.sel { border-color: var(--faint); background: var(--hover); }134+.card.sel { box-shadow: inset 2px 0 0 var(--accent); }135+.card-top { display: flex; align-items: center; gap: 10px; min-width: 0; }136+.card-mark { flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 6px; background: hsl(var(--h) 55% 68%); color: #0d1117; font-weight: 700; font-size: 0.8rem; }137+.card-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }138+.card-name::after { content: ''; position: absolute; inset: 0; border-radius: var(--radius); }139+.card-name .desc { font-weight: normal; }140+.card-desc { margin: 0; color: var(--muted); font-size: 0.9em; overflow-wrap: anywhere; }141+.card-foot { display: flex; align-items: center; gap: 10px; margin-top: auto; padding-top: 2px; font-size: 0.8em; color: var(--faint); }142+.card-foot a { position: relative; z-index: 1; }143+.card-foot .grow { flex: 1; }144+.shelves-empty { color: var(--muted); padding: 24px 0; text-align: center; }145+{% endcss %}146+{% set list = repos | shelves %}147+<div class="shelf-bar" data-shelves>148+{% if list.length > 1 %}{% for sh in list %}<a class="chip" href="#{{ sh.id }}" data-shelf="{{ sh.id }}">{{ sh.title }}<span class="n">{{ sh.repos.length }}</span></a>{% endfor %}{% endif %}149+<button type="button" class="chip" data-has-site hidden aria-pressed="false">has a site</button>150+<span class="grow"></span>151+<button type="button" class="chip" data-sort hidden title="Sort by name or by last push">newest first</button>152+</div>153+{% for sh in list %}154+<section class="shelf" data-shelf="{{ sh.id }}">155+<div class="shelf-head" id="{{ sh.id }}"><h2>{{ sh.title }}</h2><span class="n">{{ sh.repos.length }}</span></div>156+<ul class="cards nav-cards">157+{% for s in sh.repos %}158+<li class="card" data-name="{{ s.name | lower }}" data-date="{{ s.lastCommit }}"{% if s.counts.sites %} data-site{% endif %}>159+<div class="card-top"><span class="card-mark" style="--h: {{ s.name | hue }}" aria-hidden="true">{{ s.name[0] | upper }}</span><a class="card-name" href="{{ s.urls.home.href }}">{% if owners %}<span class="desc">{{ s.owner }} / </span>{% endif %}{{ s.name }}</a></div>160+{% if s.description %}<p class="card-desc">{{ s.description }}</p>{% endif %}161+<div class="card-foot">{{ sitesLink(s) }}{% if s.counts.reviews %}<a href="{{ s.urls.reviews.href }}">{{ s.counts.reviews | plural('review') }}</a>{% endif %}<span class="grow"></span>{{ s.lastCommit | date }}</div>162+</li>163+{% endfor %}164+</ul>165+</section>166+{% endfor %}167+<p class="shelves-empty" hidden>No repos here have a site.</p>168+{% endmacro %}169+116170 {% macro sitesLink(s) %}117171 {% if s.counts.sites == 1 and s.sites.length %}<a href="{{ s.sites[0].url }}">{{ s.sites[0].name }}</a>118172 {% elif s.counts.sites %}<a href="{{ s.urls.sites.href }}">sites</a>{% endif %}
M site/assets/keys.ts +3 -2
M site/assets/ui.ts +38 -0
@@ -71,9 +71,47 @@
7171 if (over > 0) tabs.scrollLeft += over + 24;7272 }7373 74+ // The index and owner pages' shelves (shelves in macros.njk). Without JS the75+ // chips jump to a shelf; here one shows that shelf alone, "has a site"76+ // hides repos without one, and the sort switches between newest and a-z.77+ // The choices outlive live.ts swapping in a rebuilt page.78+ const pick = { shelf: '', site: false, az: false };79+ const shelves = (): void => {80+ const bar = document.querySelector<HTMLElement>('[data-shelves]');81+ if (!bar) return;82+ for (const b of bar.querySelectorAll<HTMLElement>('[hidden]')) b.hidden = false;83+ for (const c of bar.querySelectorAll<HTMLElement>('[data-shelf]')) c.setAttribute('aria-pressed', String(c.dataset.shelf === pick.shelf));84+ bar.querySelector('[data-has-site]')?.setAttribute('aria-pressed', String(pick.site));85+ const sort = bar.querySelector<HTMLElement>('[data-sort]');86+ if (sort) sort.textContent = pick.az ? 'a-z' : 'newest first';87+ let shown = 0;88+ for (const sec of document.querySelectorAll<HTMLElement>('section.shelf')) {89+ const ul = sec.querySelector('ul')!;90+ const cards = [...ul.children] as HTMLElement[];91+ cards.sort((a, b) => (pick.az ? a.dataset.name!.localeCompare(b.dataset.name!) : b.dataset.date!.localeCompare(a.dataset.date!)));92+ ul.append(...cards);93+ let n = 0;94+ for (const c of cards) n += Number(!(c.hidden = pick.site && !c.hasAttribute('data-site')));95+ sec.hidden = !n || (!!pick.shelf && sec.dataset.shelf !== pick.shelf);96+ if (!sec.hidden) shown++;97+ }98+ const empty = document.querySelector<HTMLElement>('.shelves-empty');99+ if (empty) empty.hidden = shown > 0;100+ };101+ document.addEventListener('click', (e) => {102+ const el = (e.target as Element | null)?.closest<HTMLElement>('[data-shelves] .chip');103+ if (!el) return;104+ e.preventDefault();105+ if (el.dataset.shelf !== undefined) pick.shelf = pick.shelf === el.dataset.shelf ? '' : el.dataset.shelf;106+ else if (el.hasAttribute('data-has-site')) pick.site = !pick.site;107+ else if (el.hasAttribute('data-sort')) pick.az = !pick.az;108+ shelves();109+ });110+74111 const content = (): void => {75112 wrap(wrapped());76113 times();114+ shelves();77115 };78116 content();79117 setInterval(times, 60000);
M site/index.njk +1 -10
M site/owner.njk +1 -10
A test/scenarios/shelves.sh +34 -0
@@ -0,0 +1,34 @@
1+# The index and owner pages show repos as cards on shelves: the category2+# its owner wrote in the bare repo's category file, else "other" (Jev's3+# topic is off in scenarios); fuller shelves first, "other" last, newest4+# first on each. Private repos have no card, and backups keep the file.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+for r in dami/chess dami/go ana/notes; do9+ repo "$r"10+ clone dami "$r" "$T/w/${r#*/}"11+ tick 360012+ at "$T/w/${r#*/}" commit "first"13+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main14+done15+repo --private dami/secret16+printf 'Games\n' >"$FORGE_ROOT/dami/chess.git/category"17+printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"18+export FORGE_STRICT=119+build20+i=$FORGE_WWW/index.html21+has "$i" '<section class="shelf" data-shelf="games">'22+has "$i" '<section class="shelf" data-shelf="other">'23+# games (two repos) before other (one); go was pushed last, so it leads.24+eq "$(grep -o '<section class="shelf" data-shelf="[a-z]*"' "$i" | sed 's/.*="//; s/"//' | tr '\n' ' ')" "games other "25+eq "$(grep -o 'class="card-name" href="/[a-z]*/[a-z]*/"' "$i" | sed 's/.*href="//; s/"//' | tr '\n' ' ')" "/dami/go/ /dami/chess/ /ana/notes/ "26+# Several owners: each card names its owner.27+has "$i" '<span class="desc">dami / </span>go</a>'28+lacks "$i" 'secret'29+has "$FORGE_WWW/dami/index.html" '<section class="shelf" data-shelf="games">'30+lacks "$FORGE_WWW/dami/index.html" '<span class="desc">dami / </span>'31+# One owner's only shelf is "other": titled repositories.32+has "$FORGE_WWW/ana/index.html" '<h2>repositories</h2>'33+ok forge backup --dry-run34+has "$FORGE_ROOT/.forge/backup/stage/dami/chess.git/category" '^Games$'