diff --git a/src/app/(site)/friends/page.tsx b/src/app/(site)/friends/page.tsx index 2c6c5293..85a56018 100644 --- a/src/app/(site)/friends/page.tsx +++ b/src/app/(site)/friends/page.tsx @@ -5,9 +5,9 @@ import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; import { removeFriendship } from "@/actions/messenger"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { auth } from "@/lib/auth"; import { db, MessengerFriendships, User } from "@/lib/db"; -import { avatarImageUrl } from "@/lib/format"; export const metadata = { title: "Friends" }; @@ -118,10 +118,6 @@ export default async function FriendsPage({ ) : (
{friends.map((friend) => { - const avatar = avatarImageUrl(friend.look, { - size: "s", - headOnly: true, - }); const isOnline = friend.online === "1"; return (
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${friend.username}

diff --git a/src/app/(site)/guilds/[id]/page.tsx b/src/app/(site)/guilds/[id]/page.tsx index 2436d1e1..df6056e9 100644 --- a/src/app/(site)/guilds/[id]/page.tsx +++ b/src/app/(site)/guilds/[id]/page.tsx @@ -3,8 +3,8 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { db, Guilds, GuildsMembers, User } from "@/lib/db"; -import { avatarImageUrl } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; type GuildView = { @@ -202,14 +202,9 @@ export default async function GuildPage({ alignItems: "center", }} > - {/* eslint-disable-next-line @next/next/no-img-element */} - {`${m.username}

diff --git a/src/app/(site)/leaderboard/page.tsx b/src/app/(site)/leaderboard/page.tsx index c477a6f5..165a5c2f 100644 --- a/src/app/(site)/leaderboard/page.tsx +++ b/src/app/(site)/leaderboard/page.tsx @@ -2,8 +2,8 @@ import { desc, eq, inArray } from "drizzle-orm"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { db, User, UsersCurrency, UsersSettings } from "@/lib/db"; -import { avatarImageUrl } from "@/lib/format"; export const metadata = { title: "Leaderboard" }; @@ -206,23 +206,15 @@ export default async function LeaderboardPage({ {rows.map((row, i) => { - const avatar = avatarImageUrl(row.look, { - size: "s", - headOnly: true, - }); return ( - {/* eslint-disable-next-line @next/next/no-img-element */} - {`${row.username} diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index ef15e431..daa549d0 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -7,10 +7,10 @@ import { LoginForm } from "@/components/auth/login-form"; import { Clock } from "@/components/clock"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; -import { avatarImageUrl } from "@/lib/format"; import { resolveHotelName } from "@/lib/hotel-name"; import { captchaConfig } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; @@ -227,17 +227,11 @@ export default async function LoginPage() { "color-mix(in srgb, var(--color-primary) 4%, transparent)", }} > - - -

{requests.map((req) => { const from = userById.get(req.userFromId); - const avatar = avatarImageUrl(from?.look ?? "", { - size: "s", - headOnly: true, - }); return (

- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${from?.username

@@ -360,10 +352,6 @@ export default async function MessagesPage({

{messages.map((msg) => { const from = userById.get(msg.userFromId); - const avatar = avatarImageUrl(from?.look ?? "", { - size: "s", - headOnly: true, - }); const when = formatDate(new Date(msg.sendedOn * 1000)); return (
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${from?.username

- {u.username}
- {u.username} {rows.map((row, i) => { - const avatar = avatarImageUrl(row.look, { - size: "s", - headOnly: true, - }); return ( - {/* eslint-disable-next-line @next/next/no-img-element */} - {`${row.username} diff --git a/src/app/(site)/radio/shouts/page.tsx b/src/app/(site)/radio/shouts/page.tsx index 882a30b1..d4c74309 100644 --- a/src/app/(site)/radio/shouts/page.tsx +++ b/src/app/(site)/radio/shouts/page.tsx @@ -3,9 +3,9 @@ import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; import { postShout } from "@/actions/radio-shouts"; import { ContentCard, EmptyState } from "@/components/public/ui"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { auth } from "@/lib/auth"; import { db, RadioShouts, User } from "@/lib/db"; -import { avatarImageUrl } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; type SearchParams = Promise<{ posted?: string; error?: string }>; @@ -135,12 +135,6 @@ export default async function RadioShoutsPage({
{shouts.map((s) => { const author = authorById.get(Number(s.userId)); - const authorAvatar = author - ? avatarImageUrl(author.look, { - size: "s", - headOnly: true, - }) - : null; return (
- {authorAvatar ? ( - /* eslint-disable-next-line @next/next/no-img-element */ - {author?.username ) : null}
diff --git a/src/app/(site)/rankings/page.tsx b/src/app/(site)/rankings/page.tsx index d1b6eccf..71056cb5 100644 --- a/src/app/(site)/rankings/page.tsx +++ b/src/app/(site)/rankings/page.tsx @@ -1,5 +1,4 @@ import { desc } from "drizzle-orm"; -import Image from "next/image"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { @@ -8,8 +7,8 @@ import { OnlineBadge, RankBadge, } from "@/components/public/ui"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { db, User } from "@/lib/db"; -import { avatarImageUrl } from "@/lib/format"; export const metadata = { title: "Rankings" }; @@ -50,9 +49,6 @@ export default async function RankingsPage() { ) : (
{users.map((u, i) => { - const avatar = avatarImageUrl(u.look, { - size: "m", - }); return (
- {`${u.username}

diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index 5249f642..bb5bdc3f 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -7,10 +7,10 @@ import { RegisterForm } from "@/components/auth/register-form"; import { Clock } from "@/components/clock"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; -import { avatarImageUrl } from "@/lib/format"; import { resolveHotelName } from "@/lib/hotel-name"; import { captchaConfig } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; @@ -224,17 +224,11 @@ export default async function RegisterPage() { "color-mix(in srgb, var(--color-primary) 4%, transparent)", }} > - - - {u.username} - {/* eslint-disable-next-line @next/next/no-img-element */} - {f.username} + {guestbook.map((g) => { const author = authorById.get(g.userId); - const authorAvatar = author - ? avatarImageUrl(author.look, { - size: "s", - headOnly: true, - }) - : null; return (
- {authorAvatar ? ( - /* eslint-disable-next-line @next/next/no-img-element */ - {author?.username ) : null}
diff --git a/src/components/shared/public-avatar-contract.test.ts b/src/components/shared/public-avatar-contract.test.ts new file mode 100644 index 00000000..8d2563ed --- /dev/null +++ b/src/components/shared/public-avatar-contract.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const compactCallSites = new Map([ + ["src/components/top-header.tsx", 2], + ["src/app/(site)/friends/page.tsx", 1], + ["src/app/(site)/guilds/[id]/page.tsx", 1], + ["src/app/(site)/leaderboard/page.tsx", 1], + ["src/app/(site)/login/page.tsx", 2], + ["src/app/(site)/me/page.tsx", 1], + ["src/app/(site)/messages/page.tsx", 2], + ["src/app/(site)/page.tsx", 2], + ["src/app/(site)/radio/leaderboard/page.tsx", 1], + ["src/app/(site)/radio/shouts/page.tsx", 1], + ["src/app/(site)/rankings/page.tsx", 1], + ["src/app/(site)/register/page.tsx", 2], + ["src/app/(site)/search/page.tsx", 1], + ["src/app/(site)/u/[username]/page.tsx", 2], +]); + +function source(path: string): string { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("public avatar presentation contract", () => { + it.each([...compactCallSites])( + "uses the shared thumbnail in %s", + (path, expectedCount) => { + const matches = source(path).match(/ { + expect(source("src/app/(site)/me/page.tsx")).toContain( + "avatarImageUrl(user.look", + ); + expect(source("src/app/(site)/settings/page.tsx")).toContain( + "avatarImageUrl(user.look", + ); + expect(source("src/app/(site)/u/[username]/page.tsx")).toContain( + "avatarImageUrl(user.look", + ); + }); +}); diff --git a/src/components/top-header.tsx b/src/components/top-header.tsx index 569e9bd2..f8df6397 100644 --- a/src/components/top-header.tsx +++ b/src/components/top-header.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import type { Session } from "next-auth"; import { getTranslations } from "next-intl/server"; import { RoomQuickEntry } from "@/components/room-quick-entry"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { signOut } from "@/lib/auth"; import { cached } from "@/lib/cache"; import { @@ -13,7 +14,6 @@ import { User, UsersCurrency, } from "@/lib/db"; -import { avatarImageUrl } from "@/lib/format"; import { resolveHotelName } from "@/lib/hotel-name"; import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions"; @@ -77,7 +77,6 @@ export async function TopHeader({ session }: { session: Session | null }) { } } catch {} - const avatar = avatarImageUrl(look, { headOnly: true, direction: 2 }); let showAdmin = false; let showMod = false; const ctx = await getApiAdminContext(); @@ -318,16 +317,13 @@ export async function TopHeader({ session }: { session: Session | null }) { aria-label="User menu" className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden" > - {session.user.name} @@ -387,16 +383,10 @@ export async function TopHeader({ session }: { session: Session | null }) { href="/messages" className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-white/10" > - {u.username}