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;212223export 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 62 const columns =63 recent.length === 064 ? []65 : Array.from({ length: HEX_COLUMNS }, (_, i) =>66 Array.from({ length: 4 }, (_, j) => recent[(i + j) % recent.length]?.save.id),67 );68 69 const name =70 signature && isSafePath(signature) && !signature.includes("/")71 ? html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(72 signature,73 )}" alt="${handle}">`74 : handle;75 return html`<section class="profile-cover">76<div class="hex-backdrop" aria-hidden="true">${columns.map((ids) => {77 const group = html`<span class="hex-group">${ids.map(78 (id) => html`<span class="hex-id">${id}</span>`,79 )}</span>`;80 return html`<span class="hex-col">${group}${group}</span>`;81 })}</div>82<div class="profile-cover-fade"></div>83<div class="profile-identity">84<h1 class="profile-username">${name}</h1>85${since && html`<p class="profile-tagline">building since ${since}</p>`}86<span class="profile-op-badge mono">◈ operator</span>87</div>88</section>`;89}9091function identityCard(ctx: PageContext, bio: string): Html | null {92 const location = setting(ctx.kepr, "location");93 const link = setting(ctx.kepr, "link") ?? "";94 const hasLink = HTTP_URL.test(link);95 if (!bio && !location && !hasLink) return null;96 return html`<div class="profile-identity-card">97${bio && html`<p class="profile-identity-bio">${bio}</p>`}98${location && html`<p class="profile-identity-location">${location}</p>`}99${100 hasLink &&101 html`<a class="profile-website-pill" href="${link}" rel="me noopener noreferrer">${link}</a>`102}103</div>`;104}105106function stats(ctx: PageContext, library: LibraryRepo[]): Html {107 const tiles: [string, number][] = [108 ["repos", library.length],109 ["saves", library.reduce((sum, repo) => sum + repo.stats.saves, 0)],110 ["issues", publicIssueCount(ctx.kepr)],111 ["downloads", publicDownloadCount(ctx.kepr)],112 ];113 return html`<div class="profile-stats-grid">${tiles.map(114 ([label, n]) =>115 html`<div class="profile-stat-tile"><div class="profile-stat-number">${n.toLocaleString("en-US")}</div><div class="profile-stat-label">${label}</div></div>`,116 )}</div>`;117}118119120function languageBar(library: LibraryRepo[]): Html | null {121 const lines = new Map<string, number>();122 for (const repo of library) {123 for (const [language, n] of languageLines(repo.mirror)) {124 lines.set(language, (lines.get(language) ?? 0) + n);125 }126 }127 const top = [...lines].sort((a, b) => b[1] - a[1]).slice(0, BAR_LANGUAGES);128 const total = top.reduce((sum, [, n]) => sum + n, 0);129 if (total === 0) return null;130 let x = 0;131 const segments = top.map(([language, n]) => {132 const width = (100 * n) / total;133 const segment = html`<rect x="${x.toFixed(134 2,135 )}" width="${width.toFixed(2)}" height="6"><title>${language} ${Math.round(width)}%</title></rect>`;136 x += width;137 return segment;138 });139 return html`<div class="profile-languages-bar">140<svg class="profile-lang-bar" viewBox="0 0 100 6" preserveAspectRatio="none" role="img" aria-label="languages by lines">${141 segments142 }</svg>143<div class="profile-lang-labels">${top144 .slice(0, 3)145 .map(([language]) => html`<span class="profile-lang-label">${language}</span>`)}</div>146</div>`;147}148149function activity(recent: RecentSave[]): Html {150 if (recent.length === 0) return html`<p class="profile-empty-state">no activity yet</p>`;151 return html`<div class="profile-timeline" tabindex="0"152role="region" aria-label="recent activity">${recent.map(153 ({ repo, save }) =>154 html`<div class="profile-event"><a class="profile-event-repo" href="/${155 repo156 }">${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>`,157 )}</div>`;158}