Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa5347f91 | ||
|
|
0bd2ac6707 | ||
|
|
06cd0cfe42 | ||
|
|
adc201bfb6 | ||
|
|
bf24d9575a | ||
|
|
9702ab304c |
No files matched your search
@@ -0,0 +1,69 @@
|
|||||||
|
# Public Avatar Thumbnail and Currency Icon Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make avatar and currency presentation consistent across the public site:
|
||||||
|
|
||||||
|
- user thumbnails in lists and compact cards show only the avatar head at a fixed 40 x 40 pixel size;
|
||||||
|
- full-body avatars remain available on profile pages and deliberately large previews;
|
||||||
|
- currency amounts use the existing graphical currency icons instead of placeholder letters such as `c`, `cr`, `du`, or `di`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The change covers public-facing pages and shared public components. It includes rankings, leaderboards, shop and badge-purchase currency rows, plus every other compact user list or card that currently renders an avatar directly.
|
||||||
|
|
||||||
|
Admin-only screens are outside this visual cleanup unless they reuse a shared public component changed by this work. Profile hero avatars, the main current-user avatar, registration/login previews, and other intentionally large previews retain their full-avatar presentation.
|
||||||
|
|
||||||
|
## Avatar Design
|
||||||
|
|
||||||
|
Compact user representations will use one shared semantic thumbnail path rather than choosing imager options independently in each page.
|
||||||
|
|
||||||
|
The thumbnail contract is:
|
||||||
|
|
||||||
|
- request `headOnly: true` from the avatar imager;
|
||||||
|
- render at 40 x 40 CSS and image dimensions;
|
||||||
|
- preserve pixel-art rendering and contain the image without stretching;
|
||||||
|
- prevent the thumbnail container from shrinking into adjacent text;
|
||||||
|
- use the user's actual figure and the existing avatar URL fallback behavior;
|
||||||
|
- keep useful alternative text based on the displayed username where that context is available.
|
||||||
|
|
||||||
|
The existing shared avatar component will be extended with an explicit compact/head-thumbnail variant, or a narrowly focused wrapper will be added if that keeps call sites clearer. Public list and compact-card call sites will migrate to this shared contract. Large/profile call sites will remain explicit so they cannot be accidentally cropped by a global CSS rule.
|
||||||
|
|
||||||
|
## Currency Design
|
||||||
|
|
||||||
|
All public currency amount rows will use the existing `CurrencyIcon` component and the existing assets under `public/assets/images/icons/currency`.
|
||||||
|
|
||||||
|
The mapping is:
|
||||||
|
|
||||||
|
- credits: `credits.png`;
|
||||||
|
- duckets: `duckets.png`;
|
||||||
|
- diamonds/crystals: `diamonds.png`.
|
||||||
|
|
||||||
|
Icons will be decorative when the surrounding UI already names the currency, using an empty alternative text to avoid repeated screen-reader announcements. The numeric amount and existing pill/layout styling remain unchanged. Icon size will be fixed consistently for compact amount rows, with no textual placeholder left visible.
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
1. Add the shared compact avatar contract and focused tests.
|
||||||
|
2. Inventory public avatar call sites and classify each as compact thumbnail or large/profile preview.
|
||||||
|
3. Migrate every compact call site to the shared head-only 40 x 40 rendering.
|
||||||
|
4. Replace textual and empty CSS currency markers in public amount rows with `CurrencyIcon` and the correct currency kind.
|
||||||
|
5. Remove CSS rules that exist only to draw obsolete letter-based or background-only markers, while retaining layout classes still used by the amount pills.
|
||||||
|
|
||||||
|
This semantic migration is preferred over a global CSS crop because it sends the correct head-only request to the imager and does not risk changing profile avatars.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Verification will include:
|
||||||
|
|
||||||
|
- automated tests for the compact avatar contract (`headOnly`, fixed dimensions, actual figure propagation);
|
||||||
|
- source/component checks ensuring public currency rows use `CurrencyIcon` with the correct mapping and no placeholder letters remain;
|
||||||
|
- the existing test, type-check, and build commands relevant to the changed files;
|
||||||
|
- visual checks at desktop and narrow widths for rankings, leaderboard, shop, badge purchase, and representative user lists/cards;
|
||||||
|
- explicit checks that profile pages and large avatar previews still render full avatars.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- changing balances or currency business logic;
|
||||||
|
- changing the avatar imager service itself;
|
||||||
|
- redesigning profile hero sections;
|
||||||
|
- modifying admin-only layouts that do not share the affected public components.
|
||||||
+1
-1
@@ -58,7 +58,7 @@
|
|||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "7.85.0",
|
"react-hook-form": "7.85.0",
|
||||||
"resend": "6.18.1",
|
"resend": "6.19.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "0.35.3",
|
"sharp": "0.35.3",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
|
|||||||
Generated
+5
-5
@@ -119,8 +119,8 @@ importers:
|
|||||||
specifier: 7.85.0
|
specifier: 7.85.0
|
||||||
version: 7.85.0([email protected])
|
version: 7.85.0([email protected])
|
||||||
resend:
|
resend:
|
||||||
specifier: 6.18.1
|
specifier: 6.19.0
|
||||||
version: 6.18.1
|
version: 6.19.0
|
||||||
server-only:
|
server-only:
|
||||||
specifier: 0.0.1
|
specifier: 0.0.1
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
@@ -2784,8 +2784,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||||
|
|
||||||
[email protected]8.1:
|
[email protected]9.0:
|
||||||
resolution: {integrity: sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==}
|
resolution: {integrity: sha512-JnEdYnd9WyBDIzunsEZtUF8n3cBKM9SlmySaos/7wwi4sEXKG1Zz4M64VFAyk+278erX01Fq2wHQ3p7OIdPriA==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@react-email/render': '*'
|
'@react-email/render': '*'
|
||||||
@@ -5056,7 +5056,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]8.1:
|
[email protected]9.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
postal-mime: 2.7.5
|
postal-mime: 2.7.5
|
||||||
standardwebhooks: 1.0.0
|
standardwebhooks: 1.0.0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { desc, eq } from "drizzle-orm";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { buyBadge } from "@/actions/draw-badge";
|
import { buyBadge } from "@/actions/draw-badge";
|
||||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||||
|
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db, User, WebsiteDrawbadges } from "@/lib/db";
|
import { db, User, WebsiteDrawbadges } from "@/lib/db";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -193,7 +194,7 @@ export default async function DrawBadgePage({
|
|||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<h3 style={{ margin: 0 }}>{b.badgeName}</h3>
|
<h3 style={{ margin: 0 }}>{b.badgeName}</h3>
|
||||||
<span className="currency">
|
<span className="currency">
|
||||||
<span className="coin credits">cr</span>
|
<CurrencyIcon kind="credits" size={22} alt="" />
|
||||||
{price.toLocaleString()}
|
{price.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { removeFriendship } from "@/actions/messenger";
|
import { removeFriendship } from "@/actions/messenger";
|
||||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db, MessengerFriendships, User } from "@/lib/db";
|
import { db, MessengerFriendships, User } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
|
|
||||||
export const metadata = { title: "Friends" };
|
export const metadata = { title: "Friends" };
|
||||||
|
|
||||||
@@ -118,10 +118,6 @@ export default async function FriendsPage({
|
|||||||
) : (
|
) : (
|
||||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||||
{friends.map((friend) => {
|
{friends.map((friend) => {
|
||||||
const avatar = avatarImageUrl(friend.look, {
|
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
});
|
|
||||||
const isOnline = friend.online === "1";
|
const isOnline = friend.online === "1";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -133,13 +129,9 @@ export default async function FriendsPage({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<UserAvatarThumbnail
|
||||||
<img
|
figure={friend.look}
|
||||||
className="avatar"
|
|
||||||
src={avatar}
|
|
||||||
alt={`${friend.username} avatar`}
|
alt={`${friend.username} avatar`}
|
||||||
width={50}
|
|
||||||
height={50}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
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 { db, Guilds, GuildsMembers, User } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
type GuildView = {
|
type GuildView = {
|
||||||
@@ -202,14 +202,9 @@ export default async function GuildPage({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<UserAvatarThumbnail
|
||||||
<img
|
figure={m.look}
|
||||||
className="avatar"
|
|
||||||
src={avatarImageUrl(m.look)}
|
|
||||||
alt={`${m.username} avatar`}
|
alt={`${m.username} avatar`}
|
||||||
width={50}
|
|
||||||
height={75}
|
|
||||||
style={{ flex: "0 0 auto" }}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<h3 style={{ margin: "0 0 0.2rem", fontSize: "1rem" }}>
|
<h3 style={{ margin: "0 0 0.2rem", fontSize: "1rem" }}>
|
||||||
|
|||||||
@@ -2,20 +2,24 @@ import { desc, eq, inArray } from "drizzle-orm";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||||
|
import {
|
||||||
|
CurrencyIcon,
|
||||||
|
type CurrencyKind,
|
||||||
|
} from "@/components/shared/currency-icon";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
|
|
||||||
export const metadata = { title: "Leaderboard" };
|
export const metadata = { title: "Leaderboard" };
|
||||||
|
|
||||||
// Currency type ids (see UsersCurrency in src/db/schema.ts):
|
// Currency type ids (see UsersCurrency in src/db/schema.ts):
|
||||||
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
|
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "credits", label: "Credits", coin: "credits" },
|
{ key: "credits", label: "Credits" },
|
||||||
{ key: "diamonds", label: "Diamonds", coin: "diamonds" },
|
{ key: "diamonds", label: "Diamonds" },
|
||||||
{ key: "duckets", label: "Duckets", coin: "duckets" },
|
{ key: "duckets", label: "Duckets" },
|
||||||
{ key: "achievement", label: "Achievement", coin: "" },
|
{ key: "achievement", label: "Achievement" },
|
||||||
{ key: "respects", label: "Respects", coin: "" },
|
{ key: "respects", label: "Respects" },
|
||||||
{ key: "onlinetime", label: "Online Time", coin: "" },
|
{ key: "onlinetime", label: "Online Time" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type TabKey = (typeof TABS)[number]["key"];
|
type TabKey = (typeof TABS)[number]["key"];
|
||||||
@@ -155,7 +159,6 @@ export default async function LeaderboardPage({
|
|||||||
const active: TabKey = TABS.some((tab) => tab.key === type)
|
const active: TabKey = TABS.some((tab) => tab.key === type)
|
||||||
? (type as TabKey)
|
? (type as TabKey)
|
||||||
: "credits";
|
: "credits";
|
||||||
const activeTab = TABS.find((tab) => tab.key === active) ?? TABS[0];
|
|
||||||
|
|
||||||
const rows = await (active === "credits"
|
const rows = await (active === "credits"
|
||||||
? loadCreditsRows()
|
? loadCreditsRows()
|
||||||
@@ -166,10 +169,10 @@ export default async function LeaderboardPage({
|
|||||||
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
|
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
|
||||||
));
|
));
|
||||||
|
|
||||||
const valueOnly =
|
const activeCurrency: CurrencyKind | null =
|
||||||
active === "achievement" ||
|
active === "credits" || active === "diamonds" || active === "duckets"
|
||||||
active === "respects" ||
|
? active
|
||||||
active === "onlinetime";
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
@@ -206,23 +209,15 @@ export default async function LeaderboardPage({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
const avatar = avatarImageUrl(row.look, {
|
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<tr key={row.username}>
|
<tr key={row.username}>
|
||||||
<td>
|
<td>
|
||||||
<RankBadge position={i + 1} />
|
<RankBadge position={i + 1} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<UserAvatarThumbnail
|
||||||
<img
|
figure={row.look}
|
||||||
className="avatar"
|
|
||||||
src={avatar}
|
|
||||||
alt={`${row.username} avatar`}
|
alt={`${row.username} avatar`}
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -234,16 +229,20 @@ export default async function LeaderboardPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: "right" }}>
|
<td style={{ textAlign: "right" }}>
|
||||||
{valueOnly ? (
|
{activeCurrency ? (
|
||||||
<strong>{formatValue(active, row.value)}</strong>
|
|
||||||
) : (
|
|
||||||
<span
|
<span
|
||||||
className="currency"
|
className="currency"
|
||||||
style={{ display: "inline-flex" }}
|
style={{ display: "inline-flex" }}
|
||||||
>
|
>
|
||||||
<span className={`coin ${activeTab.coin}`} />
|
<CurrencyIcon
|
||||||
|
kind={activeCurrency}
|
||||||
|
size={22}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
{row.value.toLocaleString()}
|
{row.value.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<strong>{formatValue(active, row.value)}</strong>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { LoginForm } from "@/components/auth/login-form";
|
|||||||
import { Clock } from "@/components/clock";
|
import { Clock } from "@/components/clock";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { Reveal } from "@/components/motion-reveal";
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
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)",
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(u.look, {
|
figure={u.look}
|
||||||
headOnly: true,
|
|
||||||
direction: 2,
|
|
||||||
size: "s",
|
|
||||||
})}
|
|
||||||
alt=""
|
alt=""
|
||||||
width={26}
|
options={{ direction: 2 }}
|
||||||
height={32}
|
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[8px] font-bold truncate w-full text-center leading-tight"
|
className="text-[8px] font-bold truncate w-full text-center leading-tight"
|
||||||
@@ -298,17 +292,11 @@ export default async function LoginPage() {
|
|||||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(u.look, {
|
figure={u.look}
|
||||||
headOnly: true,
|
|
||||||
direction: 2,
|
|
||||||
size: "s",
|
|
||||||
})}
|
|
||||||
alt=""
|
alt=""
|
||||||
width={26}
|
options={{ direction: 2 }}
|
||||||
height={32}
|
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[8px] font-bold truncate w-full text-center leading-tight"
|
className="text-[8px] font-bold truncate w-full text-center leading-tight"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { CSSProperties, ReactNode } from "react";
|
|||||||
import { claimReferral } from "@/actions/referral";
|
import { claimReferral } from "@/actions/referral";
|
||||||
|
|
||||||
import { Reveal } from "@/components/motion-reveal";
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
@@ -496,16 +497,10 @@ export default async function MePage({
|
|||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(friend.look, {
|
figure={friend.look}
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
})}
|
|
||||||
alt=""
|
alt=""
|
||||||
width={40}
|
|
||||||
height={50}
|
|
||||||
className="rounded-lg shrink-0"
|
className="rounded-lg shrink-0"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
sendOfflineMessage,
|
sendOfflineMessage,
|
||||||
} from "@/actions/messenger";
|
} from "@/actions/messenger";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
MessengerOffline,
|
MessengerOffline,
|
||||||
User,
|
User,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
type SearchParams = Promise<{
|
type SearchParams = Promise<{
|
||||||
@@ -270,10 +270,6 @@ export default async function MessagesPage({
|
|||||||
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
|
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
|
||||||
{requests.map((req) => {
|
{requests.map((req) => {
|
||||||
const from = userById.get(req.userFromId);
|
const from = userById.get(req.userFromId);
|
||||||
const avatar = avatarImageUrl(from?.look ?? "", {
|
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={req.id}
|
key={req.id}
|
||||||
@@ -284,13 +280,9 @@ export default async function MessagesPage({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<UserAvatarThumbnail
|
||||||
<img
|
figure={from?.look ?? ""}
|
||||||
className="avatar"
|
|
||||||
src={avatar}
|
|
||||||
alt={`${from?.username ?? "User"} avatar`}
|
alt={`${from?.username ?? "User"} avatar`}
|
||||||
width={50}
|
|
||||||
height={50}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<p style={{ margin: 0 }}>
|
<p style={{ margin: 0 }}>
|
||||||
@@ -360,10 +352,6 @@ export default async function MessagesPage({
|
|||||||
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
|
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
const from = userById.get(msg.userFromId);
|
const from = userById.get(msg.userFromId);
|
||||||
const avatar = avatarImageUrl(from?.look ?? "", {
|
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
});
|
|
||||||
const when = formatDate(new Date(msg.sendedOn * 1000));
|
const when = formatDate(new Date(msg.sendedOn * 1000));
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -375,13 +363,9 @@ export default async function MessagesPage({
|
|||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<UserAvatarThumbnail
|
||||||
<img
|
figure={from?.look ?? ""}
|
||||||
className="avatar"
|
|
||||||
src={avatar}
|
|
||||||
alt={`${from?.username ?? "User"} avatar`}
|
alt={`${from?.username ?? "User"} avatar`}
|
||||||
width={50}
|
|
||||||
height={50}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<p
|
<p
|
||||||
|
|||||||
+7
-19
@@ -10,12 +10,12 @@ import { HomeLoginForm } from "@/components/auth/home-login-form";
|
|||||||
import { Clock } from "@/components/clock";
|
import { Clock } from "@/components/clock";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { Reveal } from "@/components/motion-reveal";
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { TypewriterText } from "@/components/typewriter-text";
|
import { TypewriterText } from "@/components/typewriter-text";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
@@ -478,16 +478,10 @@ export default async function Home() {
|
|||||||
className="flex flex-col items-center gap-1"
|
className="flex flex-col items-center gap-1"
|
||||||
>
|
>
|
||||||
<div className="relative overflow-visible">
|
<div className="relative overflow-visible">
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(u.look, {
|
figure={u.look}
|
||||||
headOnly: true,
|
|
||||||
direction: 3,
|
|
||||||
})}
|
|
||||||
alt={u.username}
|
alt={u.username}
|
||||||
width={64}
|
options={{ direction: 3 }}
|
||||||
height={78}
|
|
||||||
style={{ marginTop: "-8px" }}
|
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -656,17 +650,11 @@ export default async function Home() {
|
|||||||
"color-mix(in srgb, var(--color-primary) 5%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 5%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(u.look, {
|
figure={u.look}
|
||||||
headOnly: true,
|
|
||||||
direction: 2,
|
|
||||||
size: "s",
|
|
||||||
})}
|
|
||||||
alt={u.username}
|
alt={u.username}
|
||||||
width={30}
|
options={{ direction: 2 }}
|
||||||
height={36}
|
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[9px] font-bold truncate max-w-[50px] text-center leading-tight"
|
className="text-[9px] font-bold truncate max-w-[50px] text-center leading-tight"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { sql } from "drizzle-orm";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
|
|
||||||
type LeaderRow = {
|
type LeaderRow = {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -56,23 +56,15 @@ export default async function RadioLeaderboardPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
const avatar = avatarImageUrl(row.look, {
|
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<tr key={row.username}>
|
<tr key={row.username}>
|
||||||
<td>
|
<td>
|
||||||
<RankBadge position={i + 1} />
|
<RankBadge position={i + 1} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<UserAvatarThumbnail
|
||||||
<img
|
figure={row.look}
|
||||||
className="avatar"
|
|
||||||
src={avatar}
|
|
||||||
alt={`${row.username} avatar`}
|
alt={`${row.username} avatar`}
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { postShout } from "@/actions/radio-shouts";
|
import { postShout } from "@/actions/radio-shouts";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db, RadioShouts, User } from "@/lib/db";
|
import { db, RadioShouts, User } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
type SearchParams = Promise<{ posted?: string; error?: string }>;
|
type SearchParams = Promise<{ posted?: string; error?: string }>;
|
||||||
@@ -135,12 +135,6 @@ export default async function RadioShoutsPage({
|
|||||||
<div className="card-grid">
|
<div className="card-grid">
|
||||||
{shouts.map((s) => {
|
{shouts.map((s) => {
|
||||||
const author = authorById.get(Number(s.userId));
|
const author = authorById.get(Number(s.userId));
|
||||||
const authorAvatar = author
|
|
||||||
? avatarImageUrl(author.look, {
|
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={String(s.id)}
|
key={String(s.id)}
|
||||||
@@ -151,14 +145,10 @@ export default async function RadioShoutsPage({
|
|||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{authorAvatar ? (
|
{author ? (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
<UserAvatarThumbnail
|
||||||
<img
|
figure={author.look}
|
||||||
className="avatar"
|
|
||||||
src={authorAvatar}
|
|
||||||
alt={author?.username ?? "author"}
|
alt={author?.username ?? "author"}
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { desc } from "drizzle-orm";
|
import { desc } from "drizzle-orm";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
@@ -8,8 +7,9 @@ import {
|
|||||||
OnlineBadge,
|
OnlineBadge,
|
||||||
RankBadge,
|
RankBadge,
|
||||||
} from "@/components/public/ui";
|
} from "@/components/public/ui";
|
||||||
|
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
|
|
||||||
export const metadata = { title: "Rankings" };
|
export const metadata = { title: "Rankings" };
|
||||||
|
|
||||||
@@ -50,9 +50,6 @@ export default async function RankingsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||||
{users.map((u, i) => {
|
{users.map((u, i) => {
|
||||||
const avatar = avatarImageUrl(u.look, {
|
|
||||||
size: "m",
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={u.username}
|
key={u.username}
|
||||||
@@ -64,20 +61,16 @@ export default async function RankingsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RankBadge position={i + 1} />
|
<RankBadge position={i + 1} />
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
className="avatar"
|
figure={u.look}
|
||||||
src={avatar}
|
|
||||||
alt={`${u.username} avatar`}
|
alt={`${u.username} avatar`}
|
||||||
width={50}
|
|
||||||
height={90}
|
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
|
<h3 style={{ margin: "0 0 0.25rem", fontSize: "1rem" }}>
|
||||||
<Link href={`/u/${u.username}`}>{u.username}</Link>
|
<Link href={`/u/${u.username}`}>{u.username}</Link>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="currency" style={{ margin: "0 0 0.3rem" }}>
|
<p className="currency" style={{ margin: "0 0 0.3rem" }}>
|
||||||
<span className="coin credits">c</span>
|
<CurrencyIcon kind="credits" size={22} alt="" />
|
||||||
{u.credits.toLocaleString()}
|
{u.credits.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<OnlineBadge online={u.online === "1"} />
|
<OnlineBadge online={u.online === "1"} />
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { RegisterForm } from "@/components/auth/register-form";
|
|||||||
import { Clock } from "@/components/clock";
|
import { Clock } from "@/components/clock";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { Reveal } from "@/components/motion-reveal";
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
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)",
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(u.look, {
|
figure={u.look}
|
||||||
headOnly: true,
|
|
||||||
direction: 2,
|
|
||||||
size: "s",
|
|
||||||
})}
|
|
||||||
alt=""
|
alt=""
|
||||||
width={26}
|
options={{ direction: 2 }}
|
||||||
height={32}
|
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[8px] font-bold truncate w-full text-center leading-tight"
|
className="text-[8px] font-bold truncate w-full text-center leading-tight"
|
||||||
@@ -295,16 +289,11 @@ export default async function RegisterPage() {
|
|||||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(u.look, {
|
figure={u.look}
|
||||||
headOnly: true,
|
|
||||||
direction: 2,
|
|
||||||
})}
|
|
||||||
alt=""
|
alt=""
|
||||||
width={52}
|
options={{ direction: 2 }}
|
||||||
height={64}
|
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-bold truncate w-full text-center leading-tight"
|
className="text-[10px] font-bold truncate w-full text-center leading-tight"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { and, eq, like } from "drizzle-orm";
|
import { and, eq, like } from "drizzle-orm";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { db, Rooms, User } from "@/lib/db";
|
import { db, Rooms, User } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Search" };
|
export const metadata: Metadata = { title: "Search" };
|
||||||
|
|
||||||
@@ -134,15 +133,10 @@ export default async function SearchPage({
|
|||||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(u.look, {
|
figure={u.look}
|
||||||
headOnly: true,
|
|
||||||
direction: 2,
|
|
||||||
})}
|
|
||||||
alt={u.username}
|
alt={u.username}
|
||||||
width={40}
|
options={{ direction: 2 }}
|
||||||
height={48}
|
|
||||||
unoptimized
|
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { buyShopArticle } from "@/actions/shop";
|
import { buyShopArticle } from "@/actions/shop";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db";
|
import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db";
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
@@ -277,19 +278,19 @@ export default async function ShopPage({
|
|||||||
>
|
>
|
||||||
{a.credits ? (
|
{a.credits ? (
|
||||||
<span className="currency">
|
<span className="currency">
|
||||||
<span className="coin credits">cr</span>
|
<CurrencyIcon kind="credits" size={22} alt="" />
|
||||||
{a.credits.toLocaleString()}
|
{a.credits.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{a.duckets ? (
|
{a.duckets ? (
|
||||||
<span className="currency">
|
<span className="currency">
|
||||||
<span className="coin duckets">du</span>
|
<CurrencyIcon kind="duckets" size={22} alt="" />
|
||||||
{a.duckets.toLocaleString()}
|
{a.duckets.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{a.diamonds ? (
|
{a.diamonds ? (
|
||||||
<span className="currency">
|
<span className="currency">
|
||||||
<span className="coin diamonds">di</span>
|
<CurrencyIcon kind="diamonds" size={22} alt="" />
|
||||||
{a.diamonds.toLocaleString()}
|
{a.diamonds.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
OnlineBadge,
|
OnlineBadge,
|
||||||
StatBlock,
|
StatBlock,
|
||||||
} from "@/components/public/ui";
|
} from "@/components/public/ui";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
CameraWeb,
|
CameraWeb,
|
||||||
@@ -477,17 +478,7 @@ export default async function ProfilePage({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<UserAvatarThumbnail figure={f.look} alt={f.username} />
|
||||||
<img
|
|
||||||
src={avatarImageUrl(f.look, {
|
|
||||||
size: "m",
|
|
||||||
headOnly: true,
|
|
||||||
})}
|
|
||||||
alt={f.username}
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
style={{ display: "block" }}
|
|
||||||
/>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.8rem",
|
||||||
@@ -680,12 +671,6 @@ export default async function ProfilePage({
|
|||||||
<div className="grid" style={{ gap: "0.75rem" }}>
|
<div className="grid" style={{ gap: "0.75rem" }}>
|
||||||
{guestbook.map((g) => {
|
{guestbook.map((g) => {
|
||||||
const author = authorById.get(g.userId);
|
const author = authorById.get(g.userId);
|
||||||
const authorAvatar = author
|
|
||||||
? avatarImageUrl(author.look, {
|
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={String(g.id)}
|
key={String(g.id)}
|
||||||
@@ -696,14 +681,10 @@ export default async function ProfilePage({
|
|||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{authorAvatar ? (
|
{author ? (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
<UserAvatarThumbnail
|
||||||
<img
|
figure={author.look}
|
||||||
className="avatar"
|
|
||||||
src={authorAvatar}
|
|
||||||
alt={author?.username ?? "author"}
|
alt={author?.username ?? "author"}
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
|
|||||||
@@ -620,15 +620,6 @@ tbody tr:hover {
|
|||||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||||
color: var(--color-pill-text-readable, var(--color-text-readable));
|
color: var(--color-pill-text-readable, var(--color-text-readable));
|
||||||
}
|
}
|
||||||
.currency .coin {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
.nav-credit-icon {
|
.nav-credit-icon {
|
||||||
background: #e9b124 url("/assets/images/icons/currency/credits.png") center
|
background: #e9b124 url("/assets/images/icons/currency/credits.png") center
|
||||||
no-repeat;
|
no-repeat;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { HeaderUserIdentity } from "./header-user-identity";
|
||||||
|
|
||||||
|
describe("HeaderUserIdentity", () => {
|
||||||
|
it("keeps the 40px head separated from the username", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<HeaderUserIdentity
|
||||||
|
figure="hd-180-1.hr-828-45"
|
||||||
|
username="Life"
|
||||||
|
textColor="#ffffff"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("gap-2");
|
||||||
|
expect(html).toContain('width="40"');
|
||||||
|
expect(html).toContain('height="40"');
|
||||||
|
expect(html).toContain(">Life</span>");
|
||||||
|
expect(html).not.toContain("-ml-");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
|
|
||||||
|
interface HeaderUserIdentityProps {
|
||||||
|
figure: string;
|
||||||
|
username: string;
|
||||||
|
textColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderUserIdentity({
|
||||||
|
figure,
|
||||||
|
username,
|
||||||
|
textColor,
|
||||||
|
}: HeaderUserIdentityProps) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<UserAvatarThumbnail figure={figure} alt="" options={{ direction: 2 }} />
|
||||||
|
<span
|
||||||
|
className="hidden sm:inline font-semibold"
|
||||||
|
style={{ color: textColor }}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const compactCallSites = new Map<string, number>([
|
||||||
|
["src/components/top-header.tsx", 1],
|
||||||
|
["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(/<UserAvatarThumbnail\b/g) ?? [];
|
||||||
|
expect(matches).toHaveLength(expectedCount);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("preserves explicit full-avatar previews", () => {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CurrencyIcon } from "./currency-icon";
|
||||||
|
|
||||||
|
const publicCurrencyFiles = [
|
||||||
|
"src/app/(site)/rankings/page.tsx",
|
||||||
|
"src/app/(site)/leaderboard/page.tsx",
|
||||||
|
"src/app/(site)/shop/page.tsx",
|
||||||
|
"src/app/(site)/draw-badge/page.tsx",
|
||||||
|
];
|
||||||
|
|
||||||
|
function source(path: string): string {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("public currency presentation contract", () => {
|
||||||
|
it.each([
|
||||||
|
["credits", "/assets/images/icons/currency/credits.png"],
|
||||||
|
["duckets", "/assets/images/icons/currency/duckets.png"],
|
||||||
|
["diamonds", "/assets/images/icons/currency/diamonds.png"],
|
||||||
|
] as const)("renders the %s asset", (kind, expectedSrc) => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CurrencyIcon kind={kind} size={22} alt="" />,
|
||||||
|
);
|
||||||
|
expect(html).toContain(expectedSrc);
|
||||||
|
expect(html).toContain('width="22"');
|
||||||
|
expect(html).toContain('height="22"');
|
||||||
|
expect(html).toContain('alt=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(publicCurrencyFiles)("removes placeholder coins from %s", (path) => {
|
||||||
|
const contents = source(path);
|
||||||
|
expect(contents).not.toMatch(/className=(?:"coin|\{`coin)/);
|
||||||
|
expect(contents).toContain("<CurrencyIcon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the obsolete coin CSS contract", () => {
|
||||||
|
expect(source("src/app/globals.css")).not.toContain(".currency .coin");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { UserAvatarThumbnail } from "./user-avatar-thumbnail";
|
||||||
|
|
||||||
|
describe("UserAvatarThumbnail", () => {
|
||||||
|
it("renders the supplied figure as a fixed head-only thumbnail", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<UserAvatarThumbnail
|
||||||
|
figure="hd-180-1.hr-828-45"
|
||||||
|
alt="Life avatar"
|
||||||
|
options={{ direction: 3 }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("figure=hd-180-1.hr-828-45");
|
||||||
|
expect(html).toContain("headonly=1");
|
||||||
|
expect(html).toContain("size=l");
|
||||||
|
expect(html).toContain("direction=3");
|
||||||
|
expect(html).toContain('width="40"');
|
||||||
|
expect(html).toContain('height="40"');
|
||||||
|
expect(html).toContain('alt="Life avatar"');
|
||||||
|
expect(html).toContain("shrink-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { AvatarImage } from "@/components/shared/avatar-image";
|
||||||
|
import type { AvatarOptions } from "@/lib/imager";
|
||||||
|
|
||||||
|
export interface UserAvatarThumbnailProps {
|
||||||
|
figure: string;
|
||||||
|
alt: string;
|
||||||
|
options?: Omit<AvatarOptions, "headOnly" | "size">;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatarThumbnail({
|
||||||
|
figure,
|
||||||
|
alt,
|
||||||
|
options,
|
||||||
|
className = "",
|
||||||
|
}: UserAvatarThumbnailProps) {
|
||||||
|
return (
|
||||||
|
<AvatarImage
|
||||||
|
figure={figure}
|
||||||
|
options={{ ...options, headOnly: true, size: "l" }}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
alt={alt}
|
||||||
|
className={`shrink-0 ${className}`.trim()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { HeaderUserIdentity } from "@/components/header-user-identity";
|
||||||
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
||||||
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { signOut } from "@/lib/auth";
|
import { signOut } from "@/lib/auth";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +15,6 @@ import {
|
|||||||
User,
|
User,
|
||||||
UsersCurrency,
|
UsersCurrency,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
@@ -77,7 +78,6 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const avatar = avatarImageUrl(look, { headOnly: true, direction: 2 });
|
|
||||||
let showAdmin = false;
|
let showAdmin = false;
|
||||||
let showMod = false;
|
let showMod = false;
|
||||||
const ctx = await getApiAdminContext();
|
const ctx = await getApiAdminContext();
|
||||||
@@ -318,20 +318,11 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
aria-label="User menu"
|
aria-label="User menu"
|
||||||
className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden"
|
className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden"
|
||||||
>
|
>
|
||||||
<Image
|
<HeaderUserIdentity
|
||||||
className="bg-no-repeat bg-center"
|
figure={look}
|
||||||
src={avatar}
|
username={session.user.name ?? ""}
|
||||||
alt=""
|
textColor={textColor}
|
||||||
width={54}
|
|
||||||
height={62}
|
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
className="hidden sm:inline -ml-2 font-semibold"
|
|
||||||
style={{ color: textColor }}
|
|
||||||
>
|
|
||||||
{session.user.name}
|
|
||||||
</span>
|
|
||||||
</summary>
|
</summary>
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
@@ -387,16 +378,10 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
href="/messages"
|
href="/messages"
|
||||||
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-white/10"
|
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<Image
|
<UserAvatarThumbnail
|
||||||
src={avatarImageUrl(u.look, {
|
figure={u.look}
|
||||||
headOnly: true,
|
|
||||||
direction: 2,
|
|
||||||
size: "s",
|
|
||||||
})}
|
|
||||||
alt=""
|
alt=""
|
||||||
width={20}
|
options={{ direction: 2 }}
|
||||||
height={24}
|
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
<span style={{ color: textColor }}>{u.username}</span>
|
<span style={{ color: textColor }}>{u.username}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
Reference in new issue
Block a user