6 Commits
Author SHA1 Message Date
remco ffa5347f91 chore(deps): update All dependencies
CI / check (pull_request) Failing after 12s
CI / deploy (pull_request) Skipped
CI / release (pull_request) Skipped
2026-08-10 22:00:28 +00:00
Simo 0bd2ac6707 fix: separate header avatar from username
CI / check (push) Successful in 25s
CI / release (push) Skipped
CI / deploy (push) Successful in 45s
2026-08-10 18:45:38 +02:00
Simo 06cd0cfe42 fix: use currency icons in public balances
CI / check (push) Successful in 26s
CI / release (push) Skipped
CI / deploy (push) Successful in 46s
2026-08-10 18:32:17 +02:00
Simo adc201bfb6 fix: standardize public avatar thumbnails 2026-08-10 18:30:25 +02:00
Simo bf24d9575a feat: add public avatar thumbnail contract 2026-08-10 18:24:51 +02:00
Simo 9702ab304c docs: define public avatar and currency icon design 2026-08-10 18:03:35 +02:00
26 changed files with 476 additions and 355 deletions

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.
+12 -12
View File
@@ -44,24 +44,24 @@
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"json5": "2.2.3", "json5": "2.2.3",
"jszip": "3.10.1", "jszip": "3.10.1",
"lenis": "1.3.25", "lenis": "1.3.26",
"lucide-react": "1.28.0", "lucide-react": "1.31.0",
"lzma": "2.3.2", "lzma": "2.3.2",
"motion": "12.43.0", "motion": "12.43.0",
"music-metadata": "11.14.0", "music-metadata": "11.14.0",
"mysql2": "3.23.2", "mysql2": "3.23.3",
"next": "16.3.0", "next": "16.3.0",
"next-auth": "5.0.0-beta.32", "next-auth": "5.0.0-beta.32",
"next-intl": "4.13.5", "next-intl": "4.13.6",
"otplib": "13.4.1", "otplib": "13.4.1",
"pino": "10.3.1", "pino": "10.3.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-hook-form": "7.84.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.7", "sonner": "2.0.8",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"tailwindcss-animate": "1.0.7", "tailwindcss-animate": "1.0.7",
"zod": "4.4.3" "zod": "4.4.3"
@@ -71,21 +71,21 @@
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@types/node": "^26.1.2", "@types/node": "^26.2.0",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"@types/react-dom": "19.2.4", "@types/react-dom": "19.2.4",
"@vitest/coverage-v8": "4.1.10", "@vitest/coverage-v8": "4.1.10",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"husky": "^9.1.7", "husky": "^9.1.7",
"knip": "6.31.0", "knip": "6.32.1",
"lint-staged": "^17.3.0", "lint-staged": "^17.3.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postcss": "^8.5.25", "postcss": "^8.5.26",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tsx": "^4.23.5", "tsx": "^4.23.12",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "8.2.0", "vite": "8.2.1",
"vitest": "4.1.10" "vitest": "4.1.10"
} }
} }
+114 -105
View File
@@ -36,7 +36,7 @@ importers:
version: 3.2.2([email protected]) version: 3.2.2([email protected])
'@hookform/resolvers': '@hookform/resolvers':
specifier: 5.7.1 specifier: 5.7.1
version: 5.7.1(@standard-schema/[email protected])([email protected]4.0([email protected]))([email protected]) version: 5.7.1(@standard-schema/[email protected])([email protected]5.0([email protected]))([email protected])
'@next/bundle-analyzer': '@next/bundle-analyzer':
specifier: 16.3.0 specifier: 16.3.0
version: 16.3.0 version: 16.3.0
@@ -60,7 +60,7 @@ importers:
version: 3.4.13 version: 3.4.13
drizzle-orm: drizzle-orm:
specifier: 0.45.2 specifier: 0.45.2
version: 0.45.2([email protected].2(@types/node@26.1.2)) version: 0.45.2([email protected].3(@types/node@26.2.0))
hash-wasm: hash-wasm:
specifier: 4.12.0 specifier: 4.12.0
version: 4.12.0 version: 4.12.0
@@ -77,11 +77,11 @@ importers:
specifier: 3.10.1 specifier: 3.10.1
version: 3.10.1 version: 3.10.1
lenis: lenis:
specifier: 1.3.25 specifier: 1.3.26
version: 1.3.25([email protected]) version: 1.3.26([email protected])
lucide-react: lucide-react:
specifier: 1.28.0 specifier: 1.31.0
version: 1.28.0([email protected]) version: 1.31.0([email protected])
lzma: lzma:
specifier: 2.3.2 specifier: 2.3.2
version: 2.3.2 version: 2.3.2
@@ -92,17 +92,17 @@ importers:
specifier: 11.14.0 specifier: 11.14.0
version: 11.14.0([email protected]) version: 11.14.0([email protected])
mysql2: mysql2:
specifier: 3.23.2 specifier: 3.23.3
version: 3.23.2(@types/node@26.1.2) version: 3.23.3(@types/node@26.2.0)
next: next:
specifier: 16.3.0 specifier: 16.3.0
version: 16.3.0(@types/node@26.1.2)([email protected])([email protected]([email protected]))([email protected]) version: 16.3.0(@types/node@26.2.0)([email protected])([email protected]([email protected]))([email protected])
next-auth: next-auth:
specifier: 5.0.0-beta.32 specifier: 5.0.0-beta.32
version: 5.0.0-beta.32([email protected](@types/node@26.1.2)([email protected])([email protected]([email protected]))([email protected]))([email protected]) version: 5.0.0-beta.32([email protected](@types/node@26.2.0)([email protected])([email protected]([email protected]))([email protected]))([email protected])
next-intl: next-intl:
specifier: 4.13.5 specifier: 4.13.6
version: 4.13.5([email protected](@types/node@26.1.2)([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]) version: 4.13.6([email protected](@types/node@26.2.0)([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
otplib: otplib:
specifier: 13.4.1 specifier: 13.4.1
version: 13.4.1 version: 13.4.1
@@ -116,20 +116,20 @@ importers:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8([email protected]) version: 19.2.8([email protected])
react-hook-form: react-hook-form:
specifier: 7.84.0 specifier: 7.85.0
version: 7.84.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
sharp: sharp:
specifier: ^0.35.3 specifier: ^0.35.3
version: 0.35.3(@types/node@26.1.2) version: 0.35.3(@types/node@26.2.0)
sonner: sonner:
specifier: 2.0.7 specifier: 2.0.8
version: 2.0.7([email protected]([email protected]))([email protected]) version: 2.0.8(@types/[email protected])([email protected]([email protected]))([email protected])
tailwind-merge: tailwind-merge:
specifier: 3.6.0 specifier: 3.6.0
version: 3.6.0 version: 3.6.0
@@ -153,8 +153,8 @@ importers:
specifier: ^0.5.20 specifier: ^0.5.20
version: 0.5.20([email protected]) version: 0.5.20([email protected])
'@types/node': '@types/node':
specifier: ^26.1.2 specifier: ^26.2.0
version: 26.1.2 version: 26.2.0
'@types/react': '@types/react':
specifier: 19.2.18 specifier: 19.2.18
version: 19.2.18 version: 19.2.18
@@ -174,8 +174,8 @@ importers:
specifier: ^9.1.7 specifier: ^9.1.7
version: 9.1.7 version: 9.1.7
knip: knip:
specifier: 6.31.0 specifier: 6.32.1
version: 6.31.0 version: 6.32.1
lint-staged: lint-staged:
specifier: ^17.3.0 specifier: ^17.3.0
version: 17.3.0 version: 17.3.0
@@ -189,17 +189,17 @@ importers:
specifier: ^4.3.3 specifier: ^4.3.3
version: 4.3.3 version: 4.3.3
tsx: tsx:
specifier: ^4.23.5 specifier: ^4.23.12
version: 4.23.5 version: 4.23.12
typescript: typescript:
specifier: ^7.0.2 specifier: ^7.0.2
version: 7.0.2 version: 7.0.2
vite: vite:
specifier: 8.2.0 specifier: 8.2.1
version: 8.2.0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected]) version: 8.2.1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected])
vitest: vitest:
specifier: 4.1.10 specifier: 4.1.10
version: 4.1.10(@types/node@26.1.2)(@vitest/[email protected])([email protected].0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected])) version: 4.1.10(@types/node@26.2.0)(@vitest/[email protected])([email protected].1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected]))
packages: packages:
@@ -1731,8 +1731,8 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@26.1.2': '@types/node@26.2.0':
resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
@@ -2212,9 +2212,6 @@ packages:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==}
@@ -2247,8 +2244,8 @@ packages:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
[email protected].5: [email protected].6:
resolution: {integrity: sha512-sGCVm06cfsk/t9F4s9vYBCCI5k5cf2SuH21uPm6L9uK8i+bWY9NDMSUsYzYsZ4lxohTvGa3oGixtlQvZMMz5fg==} resolution: {integrity: sha512-iYZGCJZ+kX6o7GrxpVe2sOSdW86AvEqh8RQBvWeBd9jqmuABsMc2B6xongACfItLOogyIWH6GuBslNHr79OU8Q==}
[email protected]: [email protected]:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -2321,13 +2318,13 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
[email protected]1.0: [email protected]2.1:
resolution: {integrity: sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==} resolution: {integrity: sha512-mIiIHMTJVUgSlz0mxEgPt7wg8DmfbCp1Txqab3WpbMCJF7YHvHtC9jeAHHXfISMl72N8WzhyG71SQlaqCOGZtg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
[email protected]5: [email protected]6:
resolution: {integrity: sha512-mOKxayErlaONK8fm4LN3XNd99Qu4plTpn9h9qf8wxzjGrJDzuD84FYzZ81HCd6ZsWp++VWVwOzL286Pf2s2u4A==} resolution: {integrity: sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==}
peerDependencies: peerDependencies:
'@nuxt/kit': '>=3.0.0' '@nuxt/kit': '>=3.0.0'
react: '>=17.0.0' react: '>=17.0.0'
@@ -2503,8 +2500,8 @@ packages:
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
lucide-react@1.28.0: lucide-react@1.31.0:
resolution: {integrity: sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==} resolution: {integrity: sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==}
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -2564,8 +2561,8 @@ packages:
resolution: {integrity: sha512-RyOSq98kuVfXB1emJ+NjBF0av8Ph3oBuqNy+Z5sFFfLhjYrkBQEB53V8u+U0RNTVwNo20WoPUwNkfKwZfrOqmQ==} resolution: {integrity: sha512-RyOSq98kuVfXB1emJ+NjBF0av8Ph3oBuqNy+Z5sFFfLhjYrkBQEB53V8u+U0RNTVwNo20WoPUwNkfKwZfrOqmQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
[email protected].2: [email protected].3:
resolution: {integrity: sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==} resolution: {integrity: sha512-ehp9HEKr4wVJaBOUVxNFa+CNrsCCCZ6363/jbGhb7WpEmSRNIXjHBjFs5K2s2cXn7j/RhDieejsQJ6nfUwD6vQ==}
engines: {node: '>= 8.0'} engines: {node: '>= 8.0'}
peerDependencies: peerDependencies:
'@types/node': '>= 8' '@types/node': '>= 8'
@@ -2599,11 +2596,11 @@ packages:
nodemailer: nodemailer:
optional: true optional: true
[email protected].5: [email protected].6:
resolution: {integrity: sha512-Sxdgpp4y12zUYq8XzZoQQSIL9CU1T210l7dLrfVLeIykaRslBJEHNacGTy8CLqJc5uLFsmzukANbEOpa5XgAiQ==} resolution: {integrity: sha512-M2L8jtPEAXj0CPmXbiW66THdr3OnDqA9IsU1hqv3CdxtVow3Bl9eXPdT9Opeji7L4AFrUZ016dJOs+CoTw66OA==}
[email protected].5: [email protected].6:
resolution: {integrity: sha512-9tIeZmBgxS0yYw+NN0mOfLxrH/r1l9CF/A4noKCwUfTyGIHZHrUmBnvOhbNmI4TgN7Piraf+BmIDU9pnZzjF7A==} resolution: {integrity: sha512-loS6tjWWkr/IP+EV1yXUm9URB54QmZOp4+ZsMZNmeYxY8IZxLvO2esUegnXIDxj5DpK/4BsxwDGfGhlqodpkCQ==}
peerDependencies: peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
@@ -2730,8 +2727,8 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.8 react: ^19.2.8
[email protected]4.0: [email protected]5.0:
resolution: {integrity: sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==} resolution: {integrity: sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
@@ -2787,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': '*'
@@ -2854,11 +2851,15 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
[email protected].7: [email protected].8:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
peerDependencies: peerDependencies:
'@types/react': 19.2.18
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
[email protected]: [email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
@@ -2968,8 +2969,13 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected].5: [email protected].11:
resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==}
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
@@ -2999,8 +3005,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
[email protected].5: [email protected].6:
resolution: {integrity: sha512-oYpi0M6Cd7V0ZAvOPakB3kUCVzpwsVbod47G/xbZV5dvrqFMAw4fcwOS+sKpAjVdNW+CbQUeBk4Vnyzmk/07Bw==} resolution: {integrity: sha512-RLej84qL6PGTDp/PSG3tqRpwr7IvJfOu4Qfv/uyy8CrnYn1oEOQb6osNJb++jZ7FQxqN3aQ5BI7wTIerUgrgMA==}
peerDependencies: peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
@@ -3022,8 +3028,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
[email protected].0: [email protected].1:
resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -3402,10 +3408,10 @@ snapshots:
dependencies: dependencies:
'@formatjs/fast-memoize': 3.1.7 '@formatjs/fast-memoize': 3.1.7
'@hookform/[email protected](@standard-schema/[email protected])([email protected]4.0([email protected]))([email protected])': '@hookform/[email protected](@standard-schema/[email protected])([email protected]5.0([email protected]))([email protected])':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
react-hook-form: 7.84.0([email protected]) react-hook-form: 7.85.0([email protected])
optionalDependencies: optionalDependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
zod: 4.4.3 zod: 4.4.3
@@ -4160,7 +4166,7 @@ snapshots:
'@types/[email protected]': {} '@types/[email protected]': {}
'@types/node@26.1.2': '@types/node@26.2.0':
dependencies: dependencies:
undici-types: 8.3.0 undici-types: 8.3.0
@@ -4247,7 +4253,7 @@ snapshots:
obug: 2.1.4 obug: 2.1.4
std-env: 4.2.0 std-env: 4.2.0
tinyrainbow: 3.1.1 tinyrainbow: 3.1.1
vitest: 4.1.10(@types/node@26.1.2)(@vitest/[email protected])([email protected].0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected])) vitest: 4.1.10(@types/node@26.2.0)(@vitest/[email protected])([email protected].1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected]))
'@vitest/[email protected]': '@vitest/[email protected]':
dependencies: dependencies:
@@ -4258,13 +4264,13 @@ snapshots:
chai: 6.2.2 chai: 6.2.2
tinyrainbow: 3.1.1 tinyrainbow: 3.1.1
'@vitest/[email protected]([email protected].0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected]))': '@vitest/[email protected]([email protected].1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected]))':
dependencies: dependencies:
'@vitest/spy': 4.1.10 '@vitest/spy': 4.1.10
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: optionalDependencies:
vite: 8.2.0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected]) vite: 8.2.1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected])
'@vitest/[email protected]': '@vitest/[email protected]':
dependencies: dependencies:
@@ -4390,11 +4396,11 @@ snapshots:
'@drizzle-team/brocli': 0.10.2 '@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5 '@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.25.12 esbuild: 0.25.12
tsx: 4.23.5 tsx: 4.23.11
[email protected]([email protected].2(@types/node@26.1.2)): [email protected]([email protected].3(@types/node@26.2.0)):
optionalDependencies: optionalDependencies:
mysql2: 3.23.2(@types/node@26.1.2) mysql2: 3.23.3(@types/node@26.2.0)
[email protected]: {} [email protected]: {}
@@ -4491,10 +4497,6 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
resolve-pkg-maps: 1.0.0
[email protected]: [email protected]:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 resolve-pkg-maps: 1.0.0
@@ -4519,7 +4521,7 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
[email protected].5: [email protected].6:
dependencies: dependencies:
'@formatjs/icu-messageformat-parser': 3.5.16 '@formatjs/icu-messageformat-parser': 3.5.16
@@ -4589,11 +4591,11 @@ snapshots:
readable-stream: 2.3.8 readable-stream: 2.3.8
setimmediate: 1.0.5 setimmediate: 1.0.5
[email protected]1.0: [email protected]2.1:
dependencies: dependencies:
fdir: 6.5.0([email protected]) fdir: 6.5.0([email protected])
formatly: 0.3.0 formatly: 0.3.0
get-tsconfig: 4.14.0 get-tsconfig: 4.14.1
jiti: 2.7.0 jiti: 2.7.0
oxc-parser: 0.142.0 oxc-parser: 0.142.0
oxc-resolver: 11.24.2 oxc-resolver: 11.24.2
@@ -4605,7 +4607,7 @@ snapshots:
yaml: 2.9.0 yaml: 2.9.0
zod: 4.4.3 zod: 4.4.3
[email protected]5([email protected]): [email protected]6([email protected]):
optionalDependencies: optionalDependencies:
react: 19.2.8 react: 19.2.8
@@ -4723,7 +4725,7 @@ snapshots:
[email protected]: {} [email protected]: {}
lucide-react@1.28.0([email protected]): lucide-react@1.31.0([email protected]):
dependencies: dependencies:
react: 19.2.8 react: 19.2.8
@@ -4782,11 +4784,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
[email protected].2(@types/node@26.1.2): [email protected].3(@types/node@26.2.0):
dependencies: dependencies:
'@types/node': 26.1.2 '@types/node': 26.2.0
aws-ssl-profiles: 1.1.2 aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1 generate-function: 2.3.1
iconv-lite: 0.7.3 iconv-lite: 0.7.3
long: 5.3.2 long: 5.3.2
@@ -4802,32 +4803,32 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]([email protected](@types/node@26.1.2)([email protected])([email protected]([email protected]))([email protected]))([email protected]): [email protected]([email protected](@types/node@26.2.0)([email protected])([email protected]([email protected]))([email protected]))([email protected]):
dependencies: dependencies:
'@auth/core': 0.41.3 '@auth/core': 0.41.3
next: 16.3.0(@types/node@26.1.2)([email protected])([email protected]([email protected]))([email protected]) next: 16.3.0(@types/node@26.2.0)([email protected])([email protected]([email protected]))([email protected])
react: 19.2.8 react: 19.2.8
[email protected].5: {} [email protected].6: {}
[email protected].5([email protected](@types/node@26.1.2)([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]): [email protected].6([email protected](@types/node@26.2.0)([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
dependencies: dependencies:
'@formatjs/intl-localematcher': 0.8.13 '@formatjs/intl-localematcher': 0.8.13
'@parcel/watcher': 2.6.0 '@parcel/watcher': 2.6.0
'@swc/core': 1.15.47 '@swc/core': 1.15.47
icu-minify: 4.13.5 icu-minify: 4.13.6
negotiator: 1.0.0 negotiator: 1.0.0
next: 16.3.0(@types/node@26.1.2)([email protected])([email protected]([email protected]))([email protected]) next: 16.3.0(@types/node@26.2.0)([email protected])([email protected]([email protected]))([email protected])
next-intl-swc-plugin-extractor: 4.13.5 next-intl-swc-plugin-extractor: 4.13.6
po-parser: 2.1.1 po-parser: 2.1.1
react: 19.2.8 react: 19.2.8
use-intl: 4.13.5([email protected]) use-intl: 4.13.6([email protected])
optionalDependencies: optionalDependencies:
typescript: 7.0.2 typescript: 7.0.2
transitivePeerDependencies: transitivePeerDependencies:
- '@swc/helpers' - '@swc/helpers'
[email protected](@types/node@26.1.2)([email protected])([email protected]([email protected]))([email protected]): [email protected](@types/node@26.2.0)([email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/env': 16.3.0 '@next/env': 16.3.0
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
@@ -4847,7 +4848,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.3.0 '@next/swc-win32-arm64-msvc': 16.3.0
'@next/swc-win32-x64-msvc': 16.3.0 '@next/swc-win32-x64-msvc': 16.3.0
babel-plugin-react-compiler: 1.0.0 babel-plugin-react-compiler: 1.0.0
sharp: 0.35.3(@types/node@26.1.2) sharp: 0.35.3(@types/node@26.2.0)
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- '@types/node' - '@types/node'
@@ -5004,7 +5005,7 @@ snapshots:
react: 19.2.8 react: 19.2.8
scheduler: 0.27.0 scheduler: 0.27.0
[email protected]4.0([email protected]): [email protected]5.0([email protected]):
dependencies: dependencies:
react: 19.2.8 react: 19.2.8
@@ -5055,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
@@ -5098,7 +5099,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected](@types/node@26.1.2): [email protected](@types/node@26.2.0):
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
@@ -5129,7 +5130,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.3 '@img/sharp-win32-x64': 0.35.3
'@types/node': 26.1.2 '@types/node': 26.2.0
[email protected]: {} [email protected]: {}
@@ -5145,10 +5146,12 @@ snapshots:
dependencies: dependencies:
atomic-sleep: 1.0.0 atomic-sleep: 1.0.0
[email protected].7([email protected]([email protected]))([email protected]): [email protected].8(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
react: 19.2.8 react: 19.2.8
react-dom: 19.2.8([email protected]) react-dom: 19.2.8([email protected])
optionalDependencies:
'@types/react': 19.2.18
[email protected]: {} [email protected]: {}
@@ -5230,7 +5233,13 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected].5: [email protected].11:
dependencies:
esbuild: 0.25.12
optionalDependencies:
fsevents: 2.3.3
[email protected]:
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
optionalDependencies: optionalDependencies:
@@ -5272,11 +5281,11 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.18 '@types/react': 19.2.18
[email protected].5([email protected]): [email protected].6([email protected]):
dependencies: dependencies:
'@formatjs/fast-memoize': 3.1.7 '@formatjs/fast-memoize': 3.1.7
'@schummar/icu-type-parser': 1.21.5 '@schummar/icu-type-parser': 1.21.5
icu-minify: 4.13.5 icu-minify: 4.13.6
intl-messageformat: 11.2.13 intl-messageformat: 11.2.13
react: 19.2.8 react: 19.2.8
@@ -5294,7 +5303,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected].0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected]): [email protected].1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected]):
dependencies: dependencies:
lightningcss: 1.33.0 lightningcss: 1.33.0
picomatch: 4.0.5 picomatch: 4.0.5
@@ -5302,17 +5311,17 @@ snapshots:
rolldown: 1.2.2 rolldown: 1.2.2
tinyglobby: 0.2.17 tinyglobby: 0.2.17
optionalDependencies: optionalDependencies:
'@types/node': 26.1.2 '@types/node': 26.2.0
esbuild: 0.25.12 esbuild: 0.25.12
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.7.0 jiti: 2.7.0
tsx: 4.23.5 tsx: 4.23.12
yaml: 2.9.0 yaml: 2.9.0
[email protected](@types/node@26.1.2)(@vitest/[email protected])([email protected].0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected])): [email protected](@types/node@26.2.0)(@vitest/[email protected])([email protected].1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected])):
dependencies: dependencies:
'@vitest/expect': 4.1.10 '@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected].0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected])) '@vitest/mocker': 4.1.10([email protected].1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected]))
'@vitest/pretty-format': 4.1.10 '@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10 '@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10 '@vitest/snapshot': 4.1.10
@@ -5329,10 +5338,10 @@ snapshots:
tinyexec: 1.3.0 tinyexec: 1.3.0
tinyglobby: 0.2.17 tinyglobby: 0.2.17
tinyrainbow: 3.1.1 tinyrainbow: 3.1.1
vite: 8.2.0(@types/node@26.1.2)([email protected])([email protected])([email protected].5)([email protected]) vite: 8.2.1(@types/node@26.2.0)([email protected])([email protected])([email protected].12)([email protected])
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 26.1.2 '@types/node': 26.2.0
'@vitest/coverage-v8': 4.1.10([email protected]) '@vitest/coverage-v8': 4.1.10([email protected])
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
+2 -1
View File
@@ -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>
+3 -11
View File
@@ -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
View File
@@ -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" }}>
+25 -26
View File
@@ -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 -19
View File
@@ -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"
+3 -8
View File
@@ -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
+5 -21
View File
@@ -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
View File
@@ -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"
+3 -11
View File
@@ -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>
+4 -14
View File
@@ -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 }}>
+5 -12
View File
@@ -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 -18
View File
@@ -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"
+4 -10
View File
@@ -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 -3
View File
@@ -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}
+5 -24
View File
@@ -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 }}>
-9
View File
@@ -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-");
});
});
+25
View File
@@ -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()}
/>
);
}
+9 -24
View File
@@ -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>