profile.ts

5.7 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(70					signature,71				)}" alt="${handle}">`72			: handle;73	return html`<section class="profile-cover">74<div class="hex-backdrop" aria-hidden="true">${columns.map(75		(id) => html`<span class="hex-col">${id}</span>`,76	)}</div>77<div class="profile-cover-fade"></div>78<div class="profile-identity">79<h1 class="profile-username">${name}</h1>80${since && html`<p class="profile-tagline">building since ${since}</p>`}81<span class="profile-op-badge mono">◈ operator</span>82</div>83</section>`;84}8586function identityCard(ctx: PageContext, bio: string): Html | null {87	const location = setting(ctx.kepr, "location");88	const link = setting(ctx.kepr, "link") ?? "";89	const hasLink = HTTP_URL.test(link);90	if (!bio && !location && !hasLink) return null;91	return html`<div class="profile-identity-card">92${bio && html`<p class="profile-identity-bio">${bio}</p>`}93${location && html`<p class="profile-identity-location">${location}</p>`}94${95	hasLink &&96	html`<a class="profile-website-pill" href="${link}" rel="me noopener noreferrer">${link}</a>`97}98</div>`;99}100101function stats(ctx: PageContext, library: LibraryRepo[]): Html {102	const tiles: [string, number][] = [103		["repos", library.length],104		["saves", library.reduce((sum, repo) => sum + repo.stats.saves, 0)],105		["issues", publicIssueCount(ctx.kepr)],106		["downloads", publicDownloadCount(ctx.kepr)],107	];108	return html`<div class="profile-stats-grid">${tiles.map(109		([label, n]) =>110			html`<div class="profile-stat-tile"><div class="profile-stat-number">${n.toLocaleString("en-US")}</div><div class="profile-stat-label">${label}</div></div>`,111	)}</div>`;112}113114// Lines per language summed across public repos, as SVG so the widths need no inline style (§25).115function languageBar(library: LibraryRepo[]): Html | null {116	const lines = new Map<string, number>();117	for (const repo of library) {118		for (const [language, n] of languageLines(repo.mirror)) {119			lines.set(language, (lines.get(language) ?? 0) + n);120		}121	}122	const top = [...lines].sort((a, b) => b[1] - a[1]).slice(0, BAR_LANGUAGES);123	const total = top.reduce((sum, [, n]) => sum + n, 0);124	if (total === 0) return null;125	let x = 0;126	const segments = top.map(([language, n]) => {127		const width = (100 * n) / total;128		const segment = html`<rect x="${x.toFixed(129			2,130		)}" width="${width.toFixed(2)}" height="6"><title>${language} ${Math.round(width)}%</title></rect>`;131		x += width;132		return segment;133	});134	return html`<div class="profile-languages-bar">135<svg class="profile-lang-bar" viewBox="0 0 100 6" preserveAspectRatio="none" role="img" aria-label="languages by lines">${136		segments137	}</svg>138<div class="profile-lang-labels">${top139		.slice(0, 3)140		.map(([language]) => html`<span class="profile-lang-label">${language}</span>`)}</div>141</div>`;142}143144function activity(recent: RecentSave[]): Html {145	if (recent.length === 0) return html`<p class="profile-empty-state">no activity yet</p>`;146	return html`<div class="profile-timeline" tabindex="0"147role="region" aria-label="recent activity">${recent.map(148		({ repo, save }) =>149			html`<div class="profile-event"><a class="profile-event-repo" href="/${150				repo151			}">${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>`,152	)}</div>`;153}