From d39940cd59aa18f86e5229e0ff3fb96838beb266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=AA=E5=AD=90?= Date: Sat, 16 May 2026 09:57:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0html=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/activitypub.ts | 5 +- src/index.ts | 22 +- src/mastodon.ts | 7 +- src/profile.ts | 888 ++++++++++++++++++++++++++++++++++++--------- src/util.ts | 4 + 5 files changed, 740 insertions(+), 186 deletions(-) diff --git a/src/activitypub.ts b/src/activitypub.ts index 7d73906..afe7e34 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -44,7 +44,8 @@ import { id, mediaUrl, objectUrl, - profileUrl + profileUrl, + statusUrl } from "./util"; export async function webFinger(request: Request, env: Env): Promise { @@ -746,7 +747,7 @@ export function noteObject(env: Env, user: User, status: Status, opts: { to?: st attributedTo: actorUrl(env, user), content: status.content, published: status.created_at, - url: status.url, + url: statusUrl(env, user, status.id), to: opts.to ?? audience.to, cc: opts.cc ?? audience.cc, attachment: opts.attachments ?? [], diff --git a/src/index.ts b/src/index.ts index 7213b59..a3121c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,7 +88,7 @@ import { verifyCredentials } from "./mastodon"; import { processOutgoingDeliveries } from "./federation"; -import { profilePage } from "./profile"; +import { homePage, objectPage, profilePage, statusPage } from "./profile"; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -117,7 +117,10 @@ async function route(request: Request, env: Env): Promise { const path = url.pathname.replace(/\/+$/, "") || "/"; const method = request.method.toUpperCase(); - if (method === "GET" && path === "/") return nodeInfo(env); + if (method === "GET" && path === "/") { + if (wantsHtmlDocument(request)) return homePage(env); + return nodeInfo(env); + } if (method === "GET" && path === "/avatar.png") return svgResponse(AVATAR_SVG); if (method === "GET" && path === "/header.png") return svgResponse(HEADER_SVG); @@ -213,13 +216,18 @@ async function route(request: Request, env: Env): Promise { if (method === "GET" && (m = path.match(/^\/media\/(.+)$/))) return serveMedia(env, m[1]); + if (method === "GET" && (m = path.match(/^\/@([^/]+)\/([^/]+)$/))) return statusPage(env, decodeURIComponent(m[1]), decodeURIComponent(m[2])); if (method === "GET" && (m = path.match(/^\/@([^/]+)$/))) return profilePage(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/web\/@([^/]+)\/([^/]+)$/))) return statusPage(env, decodeURIComponent(m[1]), decodeURIComponent(m[2])); if (method === "GET" && (m = path.match(/^\/web\/@([^/]+)$/))) return profilePage(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/web\/statuses\/([^/]+)$/))) return objectPage(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/statuses\/([^/]+)$/))) return objectPage(env, decodeURIComponent(m[1])); if (method === "GET" && (m = path.match(/^\/web\/accounts\/([^/]+)$/))) return profilePage(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/statuses\/([^/]+)$/))) return statusPage(env, decodeURIComponent(m[1]), decodeURIComponent(m[2])); if (method === "GET" && (m = path.match(/^\/users\/([^/]+)$/))) { const username = decodeURIComponent(m[1]); - if (wantsProfileHtml(request)) return profilePage(env, username); + if (wantsHtmlDocument(request)) return profilePage(env, username); return actor(env, username); } if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/outbox$/))) return outbox(request, env, decodeURIComponent(m[1])); @@ -227,12 +235,16 @@ async function route(request: Request, env: Env): Promise { if (method === "POST" && path === "/inbox") return inboxHandler(request, env, null); if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/followers$/))) return followersCollection(env, decodeURIComponent(m[1])); if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/following$/))) return followingCollection(env, decodeURIComponent(m[1])); - if (method === "GET" && (m = path.match(/^\/objects\/([^/]+)$/))) return activityObject(env, decodeURIComponent(m[1])); + if (method === "GET" && (m = path.match(/^\/objects\/([^/]+)$/))) { + const objectId = decodeURIComponent(m[1]); + if (wantsHtmlDocument(request)) return objectPage(env, objectId); + return activityObject(env, objectId); + } return json({ error: "not_found" }, 404); } -function wantsProfileHtml(request: Request): boolean { +function wantsHtmlDocument(request: Request): boolean { const accept = request.headers.get("accept") ?? ""; return /\btext\/html\b/i.test(accept) && !/(application\/activity\+json|application\/ld\+json)/i.test(accept); } diff --git a/src/mastodon.ts b/src/mastodon.ts index 545a39b..bff5da5 100644 --- a/src/mastodon.ts +++ b/src/mastodon.ts @@ -96,6 +96,7 @@ import { objectUrl, profileUrl, safeFileName, + statusUrl, tokenString } from "./util"; @@ -626,7 +627,7 @@ async function publishStatus(env: Env, user: User, input: StatusCreateInput): Pr activityId, objectId, now, - objectId + statusUrl(env, user, statusId) ) .run(); @@ -959,7 +960,7 @@ export async function deleteStatusEndpoint(request: Request, env: Env, statusId: await env.DB.prepare( "INSERT INTO deleted_statuses (id, user_id, object_id, url, deleted_at) VALUES (?, ?, ?, ?, ?)" - ).bind(status.id, user.id, status.object_id, status.url, new Date().toISOString()).run(); + ).bind(status.id, user.id, status.object_id, statusUrl(env, user, status.id), new Date().toISOString()).run(); await env.DB.prepare("DELETE FROM statuses WHERE id = ?").bind(status.id).run(); await env.DB.prepare("DELETE FROM media WHERE status_id = ?").bind(status.id).run(); await env.DB.prepare("DELETE FROM mentions WHERE status_id = ?").bind(status.id).run(); @@ -1885,7 +1886,7 @@ function statusRecord(env: Env, status: Status, user: User, context: StatusSeria return { id: status.id, uri: status.object_id, - url: status.url, + url: statusUrl(env, user, status.id), account: context.accountByUserId.get(user.id) ?? { id: user.id, username: user.username, diff --git a/src/profile.ts b/src/profile.ts index adb2977..c48bff1 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -2,125 +2,328 @@ import { countFollowers, countFollowing, countStatuses, + getAdminUser, + getStatus, + getUserById, getUserByIdOrUsername, listMediaForStatus, listProfileFields } from "./db"; import { html } from "./http"; import type { Media, Status, User } from "./types"; -import { actorUrl, baseUrl, escapeHtml, hostFromBaseUrl, mediaUrl, objectUrl, profileUrl } from "./util"; +import { + actorUrl, + baseUrl, + escapeHtml, + hostFromBaseUrl, + mediaUrl, + objectUrl, + profileUrl, + statusUrl +} from "./util"; -type ProfileStatus = { +type FeedEntry = { status: Status; + user: User; media: Media[]; }; +type ProfileStats = { + followersCount: number; + followingCount: number; + statusesCount: number; + fields: { name: string; value: string }[]; +}; + +export async function homePage(env: Env): Promise { + const admin = await getAdminUser(env); + const [stats, feed] = await Promise.all([ + loadProfileStats(env, admin.id), + loadRecentFeed(env, 20) + ]); + + return html(renderPage({ + title: env.INSTANCE_NAME, + description: `Recent public posts on ${hostFromBaseUrl(env)}`, + canonical: baseUrl(env), + alternate: `${baseUrl(env)}/nodeinfo/2.0`, + alternateType: "application/json", + ogType: "website", + body: renderHomeBody(env, admin, stats, feed) + })); +} + export async function profilePage(env: Env, key: string): Promise { const user = await getUserByIdOrUsername(env, key); if (!user) return html(renderNotFoundPage(env, key), 404); - const [followersCount, followingCount, statusesCount, fields, rows] = await Promise.all([ - countFollowers(env, user.id), - countFollowing(env, user.id), - countStatuses(env, user.id), - listProfileFields(env, user.id), - env.DB.prepare( - "SELECT * FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted') ORDER BY created_at DESC LIMIT 20" - ).bind(user.id).all() + const [stats, feed] = await Promise.all([ + loadProfileStats(env, user.id), + loadFeedForUser(env, user.id, 20) ]); - const statuses = await Promise.all(rows.results.map(async (status) => ({ - status, - media: await listMediaForStatus(env, status.id) - }))); - - return html(renderProfilePage(env, user, { - followersCount, - followingCount, - statusesCount, - fields, - statuses + return html(renderPage({ + title: `${user.display_name || user.username} (@${user.username})`, + description: plainText(user.note) || `Account for @${user.username}@${hostFromBaseUrl(env)}`, + canonical: profileUrl(env, user), + alternate: actorUrl(env, user), + ogType: "profile", + body: renderProfileBody(env, user, stats, feed) })); } -function renderProfilePage( - env: Env, - user: User, - data: { - followersCount: number; - followingCount: number; - statusesCount: number; - fields: { name: string; value: string }[]; - statuses: ProfileStatus[]; - } -): string { - const displayName = user.display_name || user.username; - const handle = `@${user.username}@${hostFromBaseUrl(env)}`; - const avatar = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`; - const header = user.header_r2_key ? mediaUrl(env, user.header_r2_key) : `${baseUrl(env)}/header.png`; - const pageUrl = profileUrl(env, user); - const actor = actorUrl(env, user); - const description = plainText(user.note).slice(0, 180); +export async function statusPage(env: Env, username: string, statusId: string): Promise { + const user = await getUserByIdOrUsername(env, username); + if (!user) return html(renderNotFoundPage(env, `${username}/${statusId}`), 404); + const status = await getStatus(env, statusId); + if (!status || status.user_id !== user.id || !isVisibleStatus(status)) { + const tomb = await env.DB.prepare("SELECT id FROM deleted_statuses WHERE id = ? AND user_id = ?") + .bind(statusId, user.id).first<{ id: string }>(); + if (tomb) return html(renderDeletedStatusPage(env, statusId), 410); + return html(renderNotFoundPage(env, `${username}/${statusId}`), 404); + } + + const media = await listMediaForStatus(env, status.id); + return html(renderPage({ + title: statusTitle(user, status), + description: plainText(status.summary || status.content).slice(0, 180), + canonical: statusUrl(env, user, status.id), + alternate: objectUrl(env, status.id), + body: renderStatusBody(env, user, status, media, { includeProfile: true }) + })); +} + +export async function objectPage(env: Env, objectId: string): Promise { + const status = await getStatus(env, objectId); + if (!status || !isVisibleStatus(status)) { + const tomb = await env.DB.prepare("SELECT deleted_at FROM deleted_statuses WHERE id = ?").bind(objectId).first<{ deleted_at: string }>(); + if (tomb) return html(renderDeletedStatusPage(env, objectId), 410); + return html(renderNotFoundPage(env, objectId), 404); + } + + const user = await getUserById(env, status.user_id); + if (!user) return html(renderNotFoundPage(env, objectId), 404); + const media = await listMediaForStatus(env, status.id); + + return html(renderPage({ + title: statusTitle(user, status), + description: plainText(status.summary || status.content).slice(0, 180), + canonical: statusUrl(env, user, status.id), + alternate: objectUrl(env, status.id), + body: renderStatusBody(env, user, status, media, { includeProfile: true }) + })); +} + +async function loadProfileStats(env: Env, userId: string): Promise { + const [followersCount, followingCount, statusesCount, fields] = await Promise.all([ + countFollowers(env, userId), + countFollowing(env, userId), + countStatuses(env, userId), + listProfileFields(env, userId) + ]); + return { followersCount, followingCount, statusesCount, fields }; +} + +async function loadRecentFeed(env: Env, limit: number): Promise { + const rows = await env.DB.prepare( + "SELECT * FROM statuses WHERE visibility IN ('public', 'unlisted') ORDER BY created_at DESC LIMIT ?" + ).bind(limit).all(); + return loadFeedEntries(env, rows.results); +} + +async function loadFeedForUser(env: Env, userId: string, limit: number): Promise { + const rows = await env.DB.prepare( + "SELECT * FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted') ORDER BY created_at DESC LIMIT ?" + ).bind(userId, limit).all(); + return loadFeedEntries(env, rows.results); +} + +async function loadFeedEntries(env: Env, statuses: Status[]): Promise { + const entries = await Promise.all(statuses.map(async (status) => { + const user = await getUserById(env, status.user_id); + if (!user) return null; + const media = await listMediaForStatus(env, status.id); + return { status, user, media }; + })); + return entries.filter((entry): entry is FeedEntry => Boolean(entry)); +} + +function renderPage(options: { + title: string; + description: string; + canonical: string; + alternate: string; + alternateType?: string; + ogType?: string; + body: string; +}): string { return ` -${escapeHtml(displayName)} (${escapeHtml(handle)}) - - - - - - - - +${escapeHtml(options.title)} + + + + + + + -
-
+${options.body} + + + +`; +} + +function renderHomeBody(env: Env, user: User, stats: ProfileStats, feed: FeedEntry[]): string { + const avatar = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`; + const header = user.header_r2_key ? mediaUrl(env, user.header_r2_key) : `${baseUrl(env)}/header.png`; + return ` +
+
+
+

Instance

+

${escapeHtml(env.INSTANCE_NAME)}

+

${escapeHtml(hostFromBaseUrl(env))}

+
+
+
+
+
+ + + +
+

${escapeHtml(user.display_name || user.username)}

+

@${escapeHtml(user.username)}@${escapeHtml(hostFromBaseUrl(env))}

+
+
+ ${renderNote(user.note)} + ${renderFields(stats.fields)} +
+ ${renderStat(stats.statusesCount, "Posts")} + ${renderStat(stats.followingCount, "Following")} + ${renderStat(stats.followersCount, "Followers")} +
+
+ ${renderFeedSection(env, feed, "Recent public posts")} +
`; +} + +function renderProfileBody(env: Env, user: User, stats: ProfileStats, feed: FeedEntry[]): string { + const avatar = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`; + const header = user.header_r2_key ? mediaUrl(env, user.header_r2_key) : `${baseUrl(env)}/header.png`; + return ` +
+
+ Home + / + ActivityPub +
+
-

${escapeHtml(displayName)}

-

${escapeHtml(handle)}

+

${escapeHtml(user.display_name || user.username)}

+

@${escapeHtml(user.username)}@${escapeHtml(hostFromBaseUrl(env))}

${renderNote(user.note)} - ${renderFields(data.fields)} + ${renderFields(stats.fields)}
- ${renderStat(data.statusesCount, "Posts")} - ${renderStat(data.followingCount, "Following")} - ${renderStat(data.followersCount, "Followers")} + ${renderStat(stats.statusesCount, "Posts")} + ${renderStat(stats.followingCount, "Following")} + ${renderStat(stats.followersCount, "Followers")}
- ${renderStatuses(env, data.statuses)} -
- -`; + ${renderFeedSection(env, feed, "Posts")} +
`; } -function renderNotFoundPage(env: Env, key: string): string { - const title = "Profile not found"; - return ` - - - - -${title} - - - -
-
-

${title}

-

${escapeHtml(key)} on ${escapeHtml(hostFromBaseUrl(env))}

+function renderStatusBody(env: Env, user: User, status: Status, media: Media[], options: { includeProfile: boolean }): string { + const avatar = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`; + const header = user.header_r2_key ? mediaUrl(env, user.header_r2_key) : `${baseUrl(env)}/header.png`; + const permalink = statusUrl(env, user, status.id); + return ` +
+
+ Home + / + @${escapeHtml(user.username)} + / + ActivityPub +
+
+
+
+ + + +
+

${escapeHtml(user.display_name || user.username)}

+

@${escapeHtml(user.username)}@${escapeHtml(hostFromBaseUrl(env))}

+
+
+ ${status.summary ? `

${escapeHtml(status.summary)}

` : ""} + ${options.includeProfile ? renderStatusContent(status) : ""} + ${renderMediaGrid(env, media)} +
+ + ${escapeHtml(status.visibility)} + Permalink +
-
- -`; +
`; +} + +function renderFeedSection(env: Env, feed: FeedEntry[], title: string): string { + return ` +
+
+

${escapeHtml(title)}

+

${feed.length ? `${formatCount(feed.length)} items` : "No items"}

+
+ ${feed.length ? feed.map((entry) => renderFeedEntry(env, entry)).join("") : `

No public posts yet.

`} +
`; +} + +function renderFeedEntry(env: Env, entry: FeedEntry): string { + const { status, user, media } = entry; + const permalink = statusUrl(env, user, status.id); + const avatar = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`; + return ` + `; +} + +function renderStatusContent(status: Status): string { + return `
${sanitizeStatusHtml(status.content)}
`; } function renderNote(note: string): string { @@ -141,49 +344,61 @@ function renderFields(fields: { name: string; value: string }[]): string { } function renderStat(value: number, label: string): string { - return `
${formatCount(value)}
${label}
`; + return `
${formatCount(value)}
${escapeHtml(label)}
`; } -function renderStatuses(env: Env, items: ProfileStatus[]): string { - if (items.length === 0) { - return `
-

No public posts yet.

-
`; - } - - return `
- ${items.map((item) => renderStatus(env, item)).join("")} -
`; -} - -function renderStatus(env: Env, item: ProfileStatus): string { - const { status, media } = item; - const statusUrl = status.url || objectUrl(env, status.id); - return ``; -} - -function renderMedia(env: Env, media: Media[]): string { +function renderMediaGrid(env: Env, media: Media[]): string { if (media.length === 0) return ""; return `
${media.map((item) => { const url = mediaUrl(env, item.r2_key); - const alt = item.description ?? ""; + const alt = item.description?.trim() || "Image attachment"; if (item.mime_type.startsWith("image/")) { - return `${escapeHtml(alt)}`; + return ` + + ${escapeHtml(alt)} + `; } return `${escapeHtml(item.description || item.mime_type)}`; }).join("")}
`; } -function plainText(value: string): string { - return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); +function renderNotFoundPage(env: Env, key: string): string { + return renderPage({ + title: "Not found", + description: `${key} was not found on ${hostFromBaseUrl(env)}`, + canonical: baseUrl(env), + alternate: `${baseUrl(env)}/.well-known/webfinger`, + body: ` +
+
+

404

+

Not found

+

${escapeHtml(key)} on ${escapeHtml(hostFromBaseUrl(env))}

+
+
` + }); +} + +function renderDeletedStatusPage(env: Env, objectId: string): string { + return renderPage({ + title: "Post removed", + description: `This post has been removed.`, + canonical: baseUrl(env), + alternate: baseUrl(env), + body: ` +
+
+

410

+

Post removed

+

${escapeHtml(objectId)}

+
+
` + }); +} + +function statusTitle(user: User, status: Status): string { + const content = plainText(status.summary || status.content).slice(0, 70); + return content ? `${user.display_name || user.username} - ${content}` : `${user.display_name || user.username}`; } function formatDate(value: string): string { @@ -202,22 +417,40 @@ function formatCount(value: number): string { return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value); } -function profileCss(): string { +function plainText(value: string): string { + return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); +} + +function isVisibleStatus(status: Status): boolean { + return status.visibility === "public" || status.visibility === "unlisted"; +} + +function sanitizeStatusHtml(html: string): string { + return html + .replace(/<\/?(script|style|iframe|object|embed|link|meta)[^>]*>/gi, "") + .replace(/\sstyle\s*=\s*(".*?"|'.*?'|[^\s>]+)/gi, "") + .replace(/\son[a-z-]+\s*=\s*(".*?"|'.*?'|[^\s>]+)/gi, "") + .replace(/\s(href|src)\s*=\s*("|\')\s*javascript:[^"\']*\2/gi, " $1=\"#\""); +} + +function siteCss(): string { return ` :root { color-scheme: light; - --bg: #f6f7f9; + --bg: #f5f7f9; --surface: #ffffff; - --ink: #202428; - --muted: #66717c; - --line: #dce1e6; + --surface-alt: #eef2f5; + --ink: #1f2429; + --muted: #68727d; + --line: #d7dee5; --accent: #0f766e; --accent-strong: #0b5d56; --link: #1d4ed8; --warn: #9a3412; - --shadow: 0 18px 45px rgba(32, 36, 40, .08); + --shadow: 0 14px 36px rgba(31, 36, 41, .08); } * { box-sizing: border-box; } +html { scroll-behavior: smooth; } body { margin: 0; min-height: 100vh; @@ -226,15 +459,71 @@ body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.5; } -a { color: var(--link); text-decoration-thickness: .08em; text-underline-offset: .18em; } -a:focus-visible { outline: 3px solid rgba(15, 118, 110, .35); outline-offset: 3px; border-radius: 4px; } -.shell { - width: min(100%, 760px); - margin: 0 auto; - padding: 24px 16px 48px; +a { + color: var(--link); + text-decoration-thickness: .08em; + text-underline-offset: .18em; } -.profile, -.status { +a:hover { text-decoration-thickness: .12em; } +a:focus-visible, +button:focus-visible { + outline: 3px solid rgba(15, 118, 110, .35); + outline-offset: 3px; +} +img { max-width: 100%; } +.shell { + width: min(100%, 920px); + margin: 0 auto; + padding: 20px 16px 56px; +} +.shell-home, +.shell-profile, +.shell-status { + display: grid; + gap: 16px; +} +.instance-bar, +.crumbs, +.section-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; +} +.instance-bar, +.crumbs { + padding: 2px 2px 0; +} +.instance-bar h1, +.message-panel h1 { + margin: 0; + font-size: 2.1rem; + line-height: 1.05; + letter-spacing: 0; +} +.eyebrow { + margin: 0 0 4px; + color: var(--muted); + font-size: .78rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} +.subtle { + margin: 6px 0 0; + color: var(--muted); + overflow-wrap: anywhere; +} +.text-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 0; + font-weight: 650; +} +.panel, +.status-card, +.message-panel { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; @@ -243,7 +532,7 @@ a:focus-visible { outline: 3px solid rgba(15, 118, 110, .35); outline-offset: 3p } .cover { aspect-ratio: 3 / 1; - background: #d9e2e8; + background: #dce3e8; border-bottom: 1px solid var(--line); } .cover img { @@ -255,26 +544,31 @@ a:focus-visible { outline: 3px solid rgba(15, 118, 110, .35); outline-offset: 3p .identity { display: flex; align-items: end; - gap: 18px; - padding: 0 28px; + gap: 16px; + padding: 0 24px; transform: translateY(-34px); - margin-bottom: -18px; + margin-bottom: -16px; } .avatar { - width: 112px; - height: 112px; + width: 104px; + height: 104px; border-radius: 8px; border: 5px solid var(--surface); background: var(--surface); object-fit: cover; - box-shadow: 0 8px 24px rgba(32, 36, 40, .16); + box-shadow: 0 8px 22px rgba(31, 36, 41, .16); +} +.avatar-link { + display: block; + flex: none; + border-radius: 8px; } .name-block { min-width: 0; padding-bottom: 10px; } -h1 { +.name-block h1, +.name-block h2 { margin: 0; - font-size: 2.35rem; - line-height: 1.02; - letter-spacing: 0; + font-size: 1.95rem; + line-height: 1.05; } .handle { margin: 8px 0 0; @@ -283,15 +577,14 @@ h1 { overflow-wrap: anywhere; } .note { - padding: 0 28px 20px; - font-size: 1rem; + padding: 0 24px 18px; overflow-wrap: anywhere; } .fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; - margin: 0 28px 24px; + margin: 0 24px 22px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; @@ -304,7 +597,7 @@ h1 { } .fields dt { color: var(--muted); - font-size: .78rem; + font-size: .75rem; font-weight: 700; text-transform: uppercase; } @@ -319,36 +612,95 @@ h1 { border-top: 1px solid var(--line); } .stats div { - padding: 18px 12px; + padding: 16px 12px; text-align: center; } .stats div + div { border-left: 1px solid var(--line); } .stats dt { - font-size: 1.3rem; + font-size: 1.2rem; font-weight: 800; color: var(--accent-strong); } .stats dd { margin: 2px 0 0; color: var(--muted); - font-size: .88rem; + font-size: .85rem; } -.timeline { +.feed { display: grid; gap: 14px; - margin-top: 16px; } -.status { padding: 20px 22px; } -.status-meta { +.section-head { + padding: 0 2px; +} +.section-head h2 { + margin: 0; + font-size: 1.2rem; +} +.section-head p { + margin: 0; + color: var(--muted); +} +.empty { + margin: 0; + padding: 24px; + text-align: center; + color: var(--muted); + border: 1px dashed var(--line); + border-radius: 8px; + background: var(--surface); +} +.status-card { + padding: 18px 20px; + cursor: pointer; + transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease; +} +.status-card:hover { + border-color: #b8c5cf; + box-shadow: 0 18px 42px rgba(31, 36, 41, .11); +} +.status-card:active { + transform: translateY(1px); +} +.status-head { display: flex; align-items: center; - gap: 10px; - color: var(--muted); - font-size: .88rem; + justify-content: space-between; + gap: 12px; } -.status-meta span { - color: var(--warn); - font-weight: 700; +.author { + display: inline-flex; + align-items: center; + gap: 12px; + min-width: 0; + color: inherit; + text-decoration: none; +} +.author img { + width: 44px; + height: 44px; + border-radius: 8px; + object-fit: cover; + flex: none; +} +.author strong, +.author small { + display: block; + min-width: 0; +} +.author strong { + font-size: .98rem; + line-height: 1.1; +} +.author small { + color: var(--muted); + font-size: .8rem; + overflow-wrap: anywhere; +} +.time-link { + flex: none; + color: var(--muted); + font-size: .85rem; } .summary { margin: 14px 0 0; @@ -366,21 +718,48 @@ h1 { .content p:last-child { margin-bottom: 0; } .content .mention, .content .hashtag { font-weight: 650; } +.status-footer { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.status-footer { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: .85rem; +} +.profile-compact > .summary, +.profile-compact > .content, +.profile-compact > .media-grid { + margin-left: 24px; + margin-right: 24px; +} +.profile-compact > .content { + margin-top: 0; +} +.profile-compact > .status-footer { + margin: 16px 24px 0; + padding: 14px 0 18px; +} .media-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 16px; } -.media-grid a { +.media-tile, +.attachment { display: block; min-width: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; - background: #eef1f4; + background: var(--surface-alt); } -.media-grid img { +.media-tile img { display: block; width: 100%; aspect-ratio: 4 / 3; @@ -389,45 +768,202 @@ h1 { .attachment { padding: 14px; overflow-wrap: anywhere; + color: var(--ink); + text-decoration: none; } -.empty { +.lightbox[hidden] { display: none; } +.lightbox { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; +} +.lightbox-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 20, 24, .82); +} +.lightbox-panel { + position: relative; + z-index: 1; + width: min(92vw, 1180px); margin: 0; - padding: 28px; - color: var(--muted); - text-align: center; - background: var(--surface); - border: 1px dashed var(--line); + display: grid; + gap: 10px; + padding: 14px; border-radius: 8px; + background: rgba(12, 15, 18, .92); + box-shadow: 0 24px 70px rgba(0, 0, 0, .4); } -.not-found { padding: 28px; } -@media (max-width: 560px) { - .shell { padding: 12px 10px 32px; } +.lightbox-panel img { + display: block; + max-width: 100%; + max-height: 82vh; + margin: 0 auto; + object-fit: contain; +} +.lightbox-panel figcaption { + color: rgba(255, 255, 255, .8); + font-size: .9rem; + text-align: center; + overflow-wrap: anywhere; +} +.lightbox-close { + position: absolute; + top: 10px; + right: 10px; + width: 34px; + height: 34px; + border: 0; + border-radius: 8px; + background: rgba(255, 255, 255, .12); + color: #fff; + font-size: 1rem; + line-height: 1; +} +.lightbox-close:hover { background: rgba(255, 255, 255, .18); } +.shell-message { + width: min(100%, 620px); + padding-top: 24px; +} +.message-panel { + padding: 28px; +} +.profile-compact { + display: grid; +} +@media (max-width: 640px) { + .shell { + padding: 14px 10px 42px; + } + .instance-bar, + .crumbs, + .section-head, + .status-head { + align-items: start; + } + .instance-bar, + .crumbs { + flex-direction: column; + } .identity { - align-items: center; gap: 12px; padding: 0 16px; - transform: translateY(-24px); - margin-bottom: -8px; + transform: translateY(-26px); + margin-bottom: -10px; } .avatar { width: 82px; height: 82px; border-width: 4px; } - h1 { font-size: 1.7rem; } - .note { padding: 0 16px 18px; } + .name-block h1, + .name-block h2, + .instance-bar h1, + .message-panel h1 { + font-size: 1.7rem; + } + .note { + padding: 0 16px 16px; + } .fields { grid-template-columns: 1fr; margin: 0 16px 18px; } - .fields div, - .fields div:nth-child(odd) { - border-right: 0; + .stats dt { + font-size: 1.05rem; + } + .stats dd { + font-size: .78rem; + } + .status-card { + padding: 16px 14px; + } + .profile-compact > .summary, + .profile-compact > .content, + .profile-compact > .media-grid { + margin-left: 16px; + margin-right: 16px; + } + .profile-compact > .status-footer { + margin: 16px 16px 0; + } + .media-grid { + grid-template-columns: 1fr; + } + .lightbox-panel { + width: min(94vw, 1000px); + padding: 10px; + } + .lightbox-close { + top: 8px; + right: 8px; } - .stats dt { font-size: 1.05rem; } - .stats dd { font-size: .78rem; } - .status { padding: 17px 16px; } - .media-grid { grid-template-columns: 1fr; } } `; } + +function lightboxScript(): string { + return `(() => { + const overlay = document.querySelector('[data-lightbox-overlay]'); + if (!overlay) return; + const image = overlay.querySelector('[data-lightbox-image]'); + const caption = overlay.querySelector('[data-lightbox-caption]'); + const body = document.body; + + const open = (src, alt) => { + if (!(image instanceof HTMLImageElement)) return; + image.src = src; + image.alt = alt || ''; + if (caption) caption.textContent = alt || ''; + overlay.hidden = false; + body.style.overflow = 'hidden'; + const close = overlay.querySelector('[data-lightbox-close]'); + if (close instanceof HTMLElement) close.focus(); + }; + + const close = () => { + if (!(image instanceof HTMLImageElement)) return; + overlay.hidden = true; + image.removeAttribute('src'); + image.alt = ''; + if (caption) caption.textContent = ''; + body.style.overflow = ''; + }; + + document.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const trigger = target.closest('[data-lightbox-trigger]'); + if (trigger instanceof HTMLElement) { + event.preventDefault(); + open(trigger.dataset.lightboxSrc || '', trigger.dataset.lightboxAlt || ''); + return; + } + if (target.closest('[data-lightbox-close]') || target === overlay) { + event.preventDefault(); + close(); + return; + } + if (target.closest('a, button, input, textarea, select')) return; + const card = target.closest('[data-post-href]'); + if (card instanceof HTMLElement && card.dataset.postHref) { + window.location.assign(card.dataset.postHref); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !overlay.hidden) close(); + if (event.key !== 'Enter' && event.key !== ' ') return; + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (target.closest('a, button, input, textarea, select')) return; + const card = target.closest('[data-post-href]'); + if (card instanceof HTMLElement && card.dataset.postHref) { + event.preventDefault(); + window.location.assign(card.dataset.postHref); + } + }); +})();`; +} diff --git a/src/util.ts b/src/util.ts index a9c0434..33dc347 100644 --- a/src/util.ts +++ b/src/util.ts @@ -99,6 +99,10 @@ export function profileUrl(env: Env, user: User): string { return `${baseUrl(env)}/@${encodeURIComponent(user.username)}`; } +export function statusUrl(env: Env, user: User, statusId: string): string { + return `${profileUrl(env, user)}/${encodeURIComponent(statusId)}`; +} + export function objectUrl(env: Env, statusId: string): string { return `${baseUrl(env)}/objects/${statusId}`; }