profile.ts

5.5 KB · raw

1import type { Hono } from "hono";2import { isSafePath } from "../bucket/repo.ts";3import { publicIssueCount } from "../issues/store.ts";4import { languageLines } from "../mirror/queries.ts";5import { publicDownloadCount } from "../releases/releases.ts";6import { setting } from "../server/db.ts";7import { type Html, html } from "./html.ts";8import { cacheFor, notFound, type PageContext, page, relativeTime } from "./layout.ts";9import {10	type LibraryRepo,11	publicLibrary,12	type RecentSave,13	recentSaves,14	repoCards,15} from "./library.ts";1617const HEX_COLUMNS = 48;18const RECENT_ACTIVITY = 20;19const BAR_LANGUAGES = 4;20const HTTP_URL = /^https?:\/\//i;2122/** §21.2's profile of the one operator; any other handle is a 404. */23export function registerProfilePage(app: Hono, ctx: PageContext): void {24	app.get("/:handle{@[^/]+}", (c) => {25		const handle = setting(ctx.kepr, "handle") ?? "";26		if (handle === "" || c.req.param("handle") !== `@${handle}`) return notFound(c, ctx);27		const library = publicLibrary(ctx);28		const bio = setting(ctx.kepr, "bio") ?? "";29		cacheFor(c, false);30		return c.html(31			page(ctx, {32				title: `@${handle}`,33				description: bio || `@${handle}'s repositories.`,34				path: `/@${handle}`,35				body: html`<div class="profile">36${cover(ctx, handle, recentSaves(library, HEX_COLUMNS))}37<section class="profile-portrait-section">38<div class="profile-left">39${identityCard(ctx, bio)}40${stats(ctx, library)}41${languageBar(library)}42</div>43<div class="profile-right">44<div class="profile-section-label">recent activity</div>45${activity(recentSaves(library, RECENT_ACTIVITY))}46</div>47</section>48<section class="profile-repos-section">49<div class="profile-section-label">repos · ${library.length}</div>50${repoCards(library)}51</section>52</div>`,53			}),54		);55	});56}5758function cover(ctx: PageContext, handle: string, recent: RecentSave[]): Html {59	const since = setting(ctx.kepr, "building_since");60	const signature = setting(ctx.kepr, "signature");61	// The backdrop repeats the newest IDs when there are fewer saves than columns.62	const columns =63		recent.length === 064			? []65			: Array.from({ length: HEX_COLUMNS }, (_, i) => recent[i % recent.length]?.save.id);66	// Without an uploaded signature, the handle in Sacramento stands in for it, as on Kepr 2.67	const name =68		signature && isSafePath(signature) && !signature.includes("/")69			? html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(signature)}" alt="${handle}">`70			: handle;71	return html`<section class="profile-cover">72<div class="hex-backdrop" aria-hidden="true">${columns.map((id) => html`<span class="hex-col">${id}</span>`)}</div>73<div class="profile-cover-fade"></div>74<div class="profile-identity">75<h1 class="profile-username">${name}</h1>76${since && html`<p class="profile-tagline">building since ${since}</p>`}77<span class="profile-op-badge mono">◈ operator</span>78</div>79</section>`;80}8182function identityCard(ctx: PageContext, bio: string): Html | null {83	const location = setting(ctx.kepr, "location");84	const link = setting(ctx.kepr, "link") ?? "";85	const hasLink = HTTP_URL.test(link);86	if (!bio && !location && !hasLink) return null;87	return html`<div class="profile-identity-card">88${bio && html`<p class="profile-identity-bio">${bio}</p>`}89${location && html`<p class="profile-identity-location">${location}</p>`}90${hasLink && html`<a class="profile-website-pill" href="${link}" rel="me noopener noreferrer">${link}</a>`}91</div>`;92}9394function stats(ctx: PageContext, library: LibraryRepo[]): Html {95	const tiles: [string, number][] = [96		["repos", library.length],97		["saves", library.reduce((sum, repo) => sum + repo.stats.saves, 0)],98		["issues", publicIssueCount(ctx.kepr)],99		["downloads", publicDownloadCount(ctx.kepr)],100	];101	return html`<div class="profile-stats-grid">${tiles.map(([label, n]) => html`<div class="profile-stat-tile"><div class="profile-stat-number">${n.toLocaleString("en-US")}</div><div class="profile-stat-label">${label}</div></div>`)}</div>`;102}103104// Lines per language summed across public repos, as SVG so the widths need no inline style (§25).105function languageBar(library: LibraryRepo[]): Html | null {106	const lines = new Map<string, number>();107	for (const repo of library) {108		for (const [language, n] of languageLines(repo.mirror)) {109			lines.set(language, (lines.get(language) ?? 0) + n);110		}111	}112	const top = [...lines].sort((a, b) => b[1] - a[1]).slice(0, BAR_LANGUAGES);113	const total = top.reduce((sum, [, n]) => sum + n, 0);114	if (total === 0) return null;115	let x = 0;116	const segments = top.map(([language, n]) => {117		const width = (100 * n) / total;118		const segment = html`<rect x="${x.toFixed(2)}" width="${width.toFixed(2)}" height="6"><title>${language} ${Math.round(width)}%</title></rect>`;119		x += width;120		return segment;121	});122	return html`<div class="profile-languages-bar">123<svg class="profile-lang-bar" viewBox="0 0 100 6" preserveAspectRatio="none" role="img" aria-label="languages by lines">${segments}</svg>124<div class="profile-lang-labels">${top.slice(0, 3).map(([language]) => html`<span class="profile-lang-label">${language}</span>`)}</div>125</div>`;126}127128function activity(recent: RecentSave[]): Html {129	if (recent.length === 0) return html`<p class="profile-empty-state">no activity yet</p>`;130	return html`<div class="profile-timeline">${recent.map(131		({ repo, save }) =>132			html`<div class="profile-event"><a class="profile-event-repo" href="/${repo}">${repo}</a><span class="profile-event-glyph">✦</span><a class="profile-event-message" href="/${repo}/saves/${save.id}">${save.message.split("\n")[0]}</a><span class="profile-event-time">${relativeTime(save.created_at)}</span></div>`,133	)}</div>`;134}