Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3712e3905f | ||
|
|
45f5087227 | ||
|
|
f9f0d94e2c | ||
|
|
a60010e055 | ||
|
|
60dcfe8992 | ||
|
|
5be0d42bca | ||
|
|
720764bf68 | ||
|
|
25ade98989 | ||
|
|
42bc83bb28 | ||
|
|
ec0b2844c3 | ||
|
|
c599054059 | ||
|
|
ad6f5575d2 | ||
|
|
600cf07b5a |
No files matched your search
@@ -12,8 +12,7 @@ RUN npm run build
|
|||||||
# Serve the static files using a lightweight web server
|
# Serve the static files using a lightweight web server
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
# nginx.conf is a template. An env var that is not set stays in it as ${...}, and nginx then fails to start.
|
COPY --chmod=0755 docker-entrypoint.d/10-require-hosts.sh /docker-entrypoint.d/
|
||||||
ENV AUTH_HOST="" API_HOST=""
|
|
||||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
# To Be Played – Frontend
|
# To Be Played – Frontend
|
||||||
|
|
||||||
Web app of **To Be Played**, a Steam backlog tracker: import your Steam library, decide what to play, and track what
|
Web app of **To Be Played**, a backlog tracker: import your Steam and PlayStation libraries or add games by hand,
|
||||||
you finished. The UI is in German and English, with light and dark mode.
|
decide what to play, and track what you finished. The UI is in German and English, with light and dark mode.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
| Game detail | Public profile | Mobile |
|
| Dashboard | Game detail | Public profile | Mobile |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
|  |  |  |
|
|  |  |  |  |
|
||||||
|
|
||||||
To Be Played is split into three repositories:
|
To Be Played is split into four repositories:
|
||||||
|
|
||||||
| Repository | Role |
|
| Repository | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
|
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
|
||||||
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||||||
| **ToBePlayed-Frontend** (this one) | React single-page app |
|
| **ToBePlayed-Frontend** (this one) | React single-page app |
|
||||||
|
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- **Navigation**: five items (Dashboard, Library, Play next, Stats, Community) in the header from `md` (icon and label);
|
||||||
|
on a phone a bottom bar with the same five items (`BottomNav`, `src/components/navItems.js`) and a header with logo,
|
||||||
|
points chip, bell and avatar. Search ("Find games"), the wishlist and the queue are reached from the Library. The avatar
|
||||||
|
menu has "My public profile", settings, points, What's new and the role pages; an admin's pending count is amber.
|
||||||
|
Tab pages (`/settings`, `/community`) use `?tab=` (`TabNav`, `tabOf`); an unknown tab shows the first one.
|
||||||
|
- **Texts** follow one [glossary](docs/GLOSSARY.md): one name per idea in German and English (checked by `src/i18n.test.js`).
|
||||||
- **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width
|
- **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width
|
||||||
and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored
|
and less content, large = double width and more), and can add the stat groups of "Your stats" and a "Following" tile. Stored
|
||||||
in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games and games in progress (one call, `GET /api/library/dashboard-games`), "Pile of Shame" with backlog hours, its trend of the last 90
|
in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games and games in progress (one call, `GET /api/library/dashboard-games`), "Pile of Shame" with backlog hours, its trend of the last 90
|
||||||
days ("this month: +N added, -N off the pile") and a random suggestion,
|
days ("this month: +N added, -N off the pile") and a random suggestion,
|
||||||
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
||||||
@@ -29,23 +36,25 @@ To Be Played is split into three repositories:
|
|||||||
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
|
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
|
||||||
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
|
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
|
||||||
`pileGames`, so coming games are not on it; Core never picks them.
|
`pileGames`, so coming games are not on it; Core never picks them.
|
||||||
- **What to play next** (`/play-next`): seven rows of up to 3 backlog games each: up next (your own backlog order),
|
- **What to play next** (`/play-next`): seven rows of up to 3 backlog games each: queue (your own order),
|
||||||
next in series (the next part of a series you played), because you played (a similar game for each of your 3 most
|
next in series (the next part of a series you played), because you played (a similar game for each of your 3 most
|
||||||
played), same studio (a developer you played), from people you follow (a backlog game someone you follow finished or played 5 h or more), longest on your list, and mood picks. A time
|
played), same studio (a developer you played), from people you follow (a backlog game someone you follow finished or played 5 h or more), longest on your list, and mood picks. A time
|
||||||
budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun
|
budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun
|
||||||
from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click.
|
from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click.
|
||||||
On a phone each row scrolls sideways.
|
On a phone each row scrolls sideways.
|
||||||
- **Backlog order** (`/backlog`, third library tab): all backlog games in your own order. Move a game by drag (pointer
|
- **Queue** (`/backlog`, second library tab): all backlog games in your own order. Move a game by drag (pointer
|
||||||
events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
|
events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
|
||||||
move saves the whole list. The "Up next" dashboard tile shows the first games; the picker's "Up next" row shows the first games.
|
move saves the whole list. The "Queue" dashboard tile shows the first games; the "Play next" row "Queue" shows the first games.
|
||||||
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
||||||
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
||||||
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
||||||
|
The user picks a chart form per group (bars, pie, donut, line or list, `src/utils/statCharts.js`). Auth saves it
|
||||||
|
(`PUT /api/users/settings/stat-chart`).
|
||||||
- **Click a number** (stats page, dashboard, public profile, landing page, Pile Points): a number, bar, row or year
|
- **Click a number** (stats page, dashboard, public profile, landing page, Pile Points): a number, bar, row or year
|
||||||
opens the games behind it in a dialog (a bottom sheet on phones), 40 per page with "Load more". In your own
|
opens the games behind it in a dialog (a bottom sheet on phones), 40 per page with "Load more". In your own
|
||||||
views the status counts and the pile link to the library with the filter instead. Not clickable: sums (playtime),
|
views the status counts and the pile link to the library with the filter instead. Not clickable: sums (playtime),
|
||||||
averages, rates and the moon trips. The list always uses the same rules as the number.
|
averages, rates and the moon trips. The list always uses the same rules as the number.
|
||||||
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
|
- **Pile Points** (`/points`): a three-line summary on top (start +5, finish +30 to +80, 100 % +40; points buy looks), then the shop, then the detailed rules in a closed section and the history. Points for playing games off your pile (start, finish by time to beat, 100 %, month and
|
||||||
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
|
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
|
||||||
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings),
|
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings),
|
||||||
shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
|
shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
|
||||||
@@ -72,8 +81,10 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
|
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
|
||||||
finished games per year of all users.
|
finished games per year of all users.
|
||||||
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, newest
|
||||||
in library, rating or priority, paging. Each row shows the unlocked achievements. A game you own on several
|
in library, rating, priority, name, last played or time to beat (games never played or without a time to beat come last), page
|
||||||
|
numbers with Previous / Next. Each row shows the unlocked achievements, and its status badge is a menu: choosing a status saves at once
|
||||||
|
(with an Undo in the toast and the points check). While sorted by rating or priority the row shows them. A game you own on several
|
||||||
platforms is one row (Core's counted edition) with a small badge per platform next to the title (`PlatformIcons`:
|
platforms is one row (Core's counted edition) with a small badge per platform next to the title (`PlatformIcons`:
|
||||||
ST, PS or the platform name, from `platforms[]`; lucide has no brand icons).
|
ST, PS or the platform name, from `platforms[]`; lucide has no brand icons).
|
||||||
- **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's
|
- **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's
|
||||||
@@ -89,6 +100,8 @@ flowchart LR
|
|||||||
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
|
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
|
||||||
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
|
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
|
||||||
hits to add them at once through the import review.
|
hits to add them at once through the import review.
|
||||||
|
The **Find games** page (`/search`, a button in the library toolbar; a library search without a hit links to it) finds games by name: results with cover, year, platforms and an
|
||||||
|
"In library" badge; an own game opens its game page, any other hit opens a detail view with "Add".
|
||||||
- **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core
|
- **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core
|
||||||
matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports
|
matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports
|
||||||
before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the
|
before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the
|
||||||
@@ -133,11 +146,13 @@ flowchart LR
|
|||||||
- **Wishlist** with store price and discount.
|
- **Wishlist** with store price and discount.
|
||||||
- **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets
|
- **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets
|
||||||
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to
|
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to
|
||||||
verify it; unlinking is possible.
|
verify it; unlinking is possible. After a successful link the settings page opens the dashboard (success toast),
|
||||||
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
|
where the first sync starts; the other outcomes (in use, cancelled, failed) stay on the settings page.
|
||||||
|
- **Public profile** at `/u/<display name>`, opt-in in the settings (tab "Profile & community"), with perfect games and average completion. The owner sees a "Public" chip and "Copy link" on it; a private own profile says so and links to the settings.
|
||||||
The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of
|
The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of
|
||||||
"Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art
|
"Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art
|
||||||
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
|
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
|
||||||
|
- **Settings** (`/settings`) have tabs: Account & security, Appearance (with dark mode), Connections (Steam, PlayStation), Profile & community, About (build version). A switch or select saves at once, shows the new value right away, makes no success toast and goes back with an error toast when the save fails; a form with a Save button (name, password) keeps its success toast, and the name field asks before the user leaves with unsaved text.
|
||||||
- **Appearance**: accent color, corner style, wide layout, reduce animations, page background and banner from your own game art.
|
- **Appearance**: accent color, corner style, wide layout, reduce animations, page background and banner from your own game art.
|
||||||
Saved in Auth, so it follows you to every device.
|
Saved in Auth, so it follows you to every device.
|
||||||
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
|
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
|
||||||
@@ -146,9 +161,16 @@ flowchart LR
|
|||||||
- **Registration** (`/register`): no invite code; the form asks for email and display name only (no password); the page says that the user gets a mail with a link, then an admin
|
- **Registration** (`/register`): no invite code; the form asks for email and display name only (no password); the page says that the user gets a mail with a link, then an admin
|
||||||
checks the request, and stays on "registration sent" after the send. Auth answers `409 PENDING_LIMIT_REACHED` while
|
checks the request, and stays on "registration sent" after the send. Auth answers `409 PENDING_LIMIT_REACHED` while
|
||||||
20 wait. The mail link opens `/confirm-email#<token>` (the token stays in the fragment and goes to Auth in a POST
|
20 wait. The mail link opens `/confirm-email#<token>` (the token stays in the fragment and goes to Auth in a POST
|
||||||
body); the page asks for the password (and its repeat) and confirms on a button click, not on load, so mail scanners do not burn the link. An expired or used
|
body); the page asks for the password (and its repeat, both with a show/hide toggle and the rules as helper text) and confirms on a button click, not on load, so mail scanners do not burn the link. Only a `410` (or a `400` that names the `token` field) shows "link
|
||||||
link and the page after the send offer "send the link
|
invalid"; any other `400` is a form error and keeps the typed password. An expired or used link and the page after
|
||||||
again" (same answer for any address).
|
the send offer "send the link again" (same answer for any address). After the send the page shows the address used
|
||||||
|
and "Wrong address? Start over", which brings the form back. After the confirmation the page says that a mail comes
|
||||||
|
once an admin has approved the account (no waiting time, no login link).
|
||||||
|
- **Login** (`/login`): the form checks both fields before it sends (zod, like the other forms) and has a show/hide
|
||||||
|
toggle on the password. Auth answers every failed login with `401` (a wrong password, an unknown address and an
|
||||||
|
unconfirmed one look the same), so after a `401` the page offers "Not confirmed yet? Send the link again" with the
|
||||||
|
typed address.
|
||||||
|
- **Language default**: the cached language, else the browser language (`de*` gives German, any other English), else German.
|
||||||
- **Forgot password** (`/reset-password`): the login page links to it. Without a fragment it asks for the email and
|
- **Forgot password** (`/reset-password`): the login page links to it. Without a fragment it asks for the email and
|
||||||
shows the same text for any address. The mail link opens `/reset-password#<token>` (fragment, sent in a POST body on
|
shows the same text for any address. The mail link opens `/reset-password#<token>` (fragment, sent in a POST body on
|
||||||
the button click only): the page asks for the new password twice. A used or expired link shows the email form again.
|
the button click only): the page asks for the new password twice. A used or expired link shows the email form again.
|
||||||
@@ -174,21 +196,39 @@ flowchart LR
|
|||||||
Admins turn each on or off on `/admin/hints`. Hint and news text may use `**bold**` and `[text](url)` (an app
|
Admins turn each on or off on `/admin/hints`. Hint and news text may use `**bold**` and `[text](url)` (an app
|
||||||
path or https; anything else stays plain text); `HintText` renders React elements, never HTML.
|
path or https; anything else stays plain text); `HintText` renders React elements, never HTML.
|
||||||
- **Follow users** (#27): a logged-in visitor follows the owner of a public profile with "Follow" on `/u/<name>`; the
|
- **Follow users** (#27): a logged-in visitor follows the owner of a public profile with "Follow" on `/u/<name>`; the
|
||||||
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private
|
Community page (`/community`) has the tabs "Following" ("Unfollow"), "Followers" ("Remove", a private follower shows as
|
||||||
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
"Private user"), "Find users" (search public profiles by name, `GET /api/follows/players?q=` in Core, 30 searches per
|
||||||
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
|
minute, only public profiles) and "Community stats" (the numbers of all users, also on the landing page). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
||||||
and marks them read; a user who goes private ends the follow with a note. Two more notes come from the user's own
|
adds; what the profile hides is left out). `/inbox` (the bell in the header) is the one place for what is new: an "N open questions" row that links to `/questions`,
|
||||||
|
the news the user has not seen (marked seen on open, "All news" links to `/whats-new`) and the newest 50 notes (marked
|
||||||
|
read on open). The bell badge is the sum of unread notes, unseen news and open questions (`useNotifications`, loaded on
|
||||||
|
every page change; 0 on `/inbox` itself, and open questions count again after the visit until answered); a digest note opens inline (click) and lists its games, achievements, status changes and wishlist
|
||||||
|
items, while you still follow the person and the part is public (5 achievements per game, then "show all"); a user
|
||||||
|
who goes private ends the follow with a note. Two more notes come from the user's own
|
||||||
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
|
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
|
||||||
settings. Notes older than 90 days are deleted.
|
settings. Notes older than 90 days are deleted.
|
||||||
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
|
The feed links on (#52): the person's name goes to their profile while it is public (`profileName`), a game goes to
|
||||||
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
|
your own game page (`/library/<appid>`), and "on their profile" opens `/u/<name>?game=<appid>`, which shows that
|
||||||
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
|
game above the tiles (`ProfileGameCard`: status, playtime, achievements, only what the profile shares; one message
|
||||||
|
when it hides or does not have the game). A game you do not own opens a catalog view (`CatalogGameCard`, `GET
|
||||||
|
/api/library/games/<appid>/preview`) with an "Add to my library" button that opens the add search with the name filled
|
||||||
|
in. The "Following" tab (`/community?tab=following`, the old `/following` redirects there, #51) lists the people you follow, newest feed message first, with an initial
|
||||||
|
letter instead of an avatar, and "Unfollow"; the "Following" dashboard tile (opt-in via "Customize") shows the first
|
||||||
|
3, 5 or 10 of them and loads its own data.
|
||||||
|
- **What's new**: unseen news entries show on `/inbox` (no dialog on the dashboard) and are marked seen in Auth when the
|
||||||
|
inbox opens. `/whats-new` (avatar menu) is the archive: it lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
|
||||||
- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to
|
- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to
|
||||||
picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts.
|
picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts.
|
||||||
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
|
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
|
||||||
dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from
|
dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from
|
||||||
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
|
`/me` and the library summary. "Next steps" come first; the main button is the first open step ("Link Steam", then
|
||||||
links to it again.
|
"Import games"), the one other button is "Go to dashboard". Every way out (buttons, step links, feature links)
|
||||||
|
stamps it in Auth first, so the gate does not send the user back. The settings page links to it again.
|
||||||
|
- **Empty library** (dashboard): a card above the tiles. Without a Steam ID or PlayStation link: "Link Steam /
|
||||||
|
PlayStation" and "Import games". With Steam, a finished sync and no games: Steam's "Game details" must be public,
|
||||||
|
with a link to the Steam privacy settings (also in the empty library list); no such text for a PlayStation-only user.
|
||||||
|
The "no Steam" and "empty library" hints stay until the user closes them. Texts are platform neutral ("Welcome"
|
||||||
|
before the first sync, "Games", "Syncing your library").
|
||||||
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
||||||
speedrun link, moods, endless flag, genres and time to beat (hours and minutes) for every user; each field goes back to the source on its own.
|
speedrun link, moods, endless flag, genres and time to beat (hours and minutes) for every user; each field goes back to the source on its own.
|
||||||
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
|
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
|
||||||
@@ -214,9 +254,11 @@ flowchart LR
|
|||||||
- react-hook-form + zod for forms, i18next for translations (`public/i18n`)
|
- react-hook-form + zod for forms, i18next for translations (`public/i18n`)
|
||||||
- Vitest + React Testing Library + jsdom
|
- Vitest + React Testing Library + jsdom
|
||||||
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
|
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
|
||||||
and security headers
|
and security headers. nginx reads `AUTH_HOST` and `API_HOST` from the container env at start (`nginx.conf` is an
|
||||||
|
nginx template) and puts them into the CSP `connect-src`.
|
||||||
- ESLint with the React hooks rules (`npm run lint`)
|
- ESLint with the React hooks rules (`npm run lint`)
|
||||||
- CI: Forgejo Actions (`npm ci`, `npm run lint`, `npm test`, `npm run build`, Docker image)
|
- CI: Gitea Actions (`.gitea/workflows/docker-publish.yml`: `npm ci`, `npm run lint`, `npm test`, `npm run build`, then the
|
||||||
|
Docker image tagged `latest`, `<branch>`, `<branch>-<sha>`)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -257,6 +299,8 @@ flowchart LR
|
|||||||
- The pages that need a login sit under the `RequireSession` layout route in `App.jsx`. Without a session it opens
|
- The pages that need a login sit under the `RequireSession` layout route in `App.jsx`. Without a session it opens
|
||||||
the login page: after a `401` with the "session expired" message and back to the same page, after a logout
|
the login page: after a `401` with the "session expired" message and back to the same page, after a logout
|
||||||
without a way back. A public page stays open when the session ends there.
|
without a way back. A public page stays open when the session ends there.
|
||||||
|
- Public routes: `/`, `/login`, `/register`, `/confirm-email`, `/reset-password`, `/u/:name` and `/impressum`
|
||||||
|
(`ImprintPage`, linked from the footer). Every other route sits under `RequireSession`.
|
||||||
- `CurrentUserProvider` holds `GET /api/users/me` for the whole app (`useCurrentUserContext`, `useCurrentUser`).
|
- `CurrentUserProvider` holds `GET /api/users/me` for the whole app (`useCurrentUserContext`, `useCurrentUser`).
|
||||||
Dashboard and settings load it again when they open.
|
Dashboard and settings load it again when they open.
|
||||||
- Tests render through `src/test/renderWithProviders.jsx` (a data router like `main.jsx`) and answer requests
|
- Tests render through `src/test/renderWithProviders.jsx` (a data router like `main.jsx`) and answer requests
|
||||||
@@ -278,11 +322,12 @@ flowchart LR
|
|||||||
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
|
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
|
||||||
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
|
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
|
||||||
```
|
```
|
||||||
- `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the
|
- `/questions` (`QuestionsPage`, reached from the bell and the inbox row "N open questions"; the title shows the live
|
||||||
live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides
|
count) starts with a short intro (where questions come from, what an answer gives, what "No" does) and lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides
|
||||||
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
|
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
|
||||||
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
|
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `GameHeroCard`.
|
||||||
Below the cards, a closed "Removed games" section (`RemovedGamesSection`, `GET /api/library/removed-games`) has a
|
Every card shows its points (+5 for a start, up to N or "by time to beat" for a finish, none for dropped and rate).
|
||||||
|
Removed games are the library filter "Removed" (`/library?removed=true`, `RemovedGamesList`, `GET /api/library/removed-games`) with a
|
||||||
Restore button per game (`POST /api/library/games/{appid}/restore`). The toast after "Remove from library" on a
|
Restore button per game (`POST /api/library/games/{appid}/restore`). The toast after "Remove from library" on a
|
||||||
manual game has an Undo that restores it the same way.
|
manual game has an Undo that restores it the same way.
|
||||||
|
|
||||||
@@ -306,22 +351,29 @@ flowchart LR
|
|||||||
Detail -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
|
Detail -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
|
||||||
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
|
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
|
||||||
Split --> Reload["reload the detail"]
|
Split --> Reload["reload the detail"]
|
||||||
Backlog["BacklogCard save"] -->|"game has editions"| Reload
|
Backlog["GameHeroCard / BacklogCard save"] -->|"game has editions"| Reload
|
||||||
```
|
```
|
||||||
|
- The game page edits at once: status (menu with Undo) and rating in `GameHeroCard`, priority in `BacklogCard`; only the
|
||||||
|
notes there have a Save button. Background, banner, profile art, hide (with Undo) and the auditor tools sit in the "..." menu of the hero card.
|
||||||
- `EditionsCard` shows "Not the same game" on every game that has other editions in the own library. The call goes
|
- `EditionsCard` shows "Not the same game" on every game that has other editions in the own library. The call goes
|
||||||
through `src/utils/links.js` (`splitGame`); `404 NOT_LINKED` shows the generic error toast.
|
through `src/utils/links.js` (`splitGame`) after a confirm dialog (unlink asks too); `404 NOT_LINKED` shows the generic error toast.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
|
Header["Bell badge (useNotifications)"] --> Count["Core /api/library/questions/count"]
|
||||||
|
Header --> Inbox["Core /api/inbox, Auth /api/news"]
|
||||||
|
InboxPage["InboxPage"] --> Count
|
||||||
Page["QuestionsPage"] --> List["Core /api/library/questions"]
|
Page["QuestionsPage"] --> List["Core /api/library/questions"]
|
||||||
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
|
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
|
||||||
Page -->|"Removed games, Restore"| Removed["Core /api/library/removed-games, POST /games/{appid}/restore"]
|
Library["Library filter Removed"] -->|"Restore"| Removed["Core /api/library/removed-games, POST /games/{appid}/restore"]
|
||||||
```
|
```
|
||||||
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
|
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
|
||||||
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
|
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
|
||||||
saved on its own (`PUT /api/users/settings/owner-lists`), never through `saveSettings`.
|
saved on its own (`PUT /api/users/settings/owner-lists`), never through `saveSettings`.
|
||||||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
- `useLibrarySync` polls Core's `/api/library/sync-status` while a sync runs, then loads the data again. While a first
|
||||||
|
sync is expected but Core has no status row (`queued`: "First sync queued ..." with "usually under a minute"), it
|
||||||
|
polls every 3 s for 15 s, then every 15 s, for 10 minutes in all; a run still going after that shows a calm "check
|
||||||
|
back later" text.
|
||||||
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
|
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
|
||||||
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
|
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
|
||||||
through the login page.
|
through the login page.
|
||||||
@@ -338,13 +390,16 @@ flowchart LR
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
|
├── App.jsx, main.jsx, i18n.js # Routes, entry point, i18next setup
|
||||||
|
├── style.css, skins.css, effects.css # Base styles, the skins, the background effects
|
||||||
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
|
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
|
||||||
├── components/ # Cards and shared components; ui/ holds the base components, import/ and stats/ the parts
|
├── components/ # Cards and shared components; ui/ holds the base components, import/, stats/ and people/ the
|
||||||
│ # of one page that share page-only code
|
│ # parts of one page that share page-only code
|
||||||
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
|
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
|
||||||
├── context/ # PreferencesContext: theme, language, appearance
|
├── context/ # CurrentUserContext, PreferencesContext
|
||||||
|
├── lib/ # utils.js (class name helper)
|
||||||
├── utils/ # Validation schemas and helpers
|
├── utils/ # Validation schemas and helpers
|
||||||
└── test/ # Test setup (i18n mock)
|
└── test/ # renderWithProviders.jsx, fetchRoutes.js, i18n mock, WalletLoader, fixtures/import
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests sit next to the code they test (`Component.test.jsx`).
|
Tests sit next to the code they test (`Component.test.jsx`).
|
||||||
@@ -363,7 +418,7 @@ other, then start them in this order (Core also starts without Auth and finds it
|
|||||||
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
||||||
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
||||||
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
||||||
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
|
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
|
||||||
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
||||||
|
|
||||||
The dev user is `PLATFORM_ADMIN`, so user, auditor and admin pages can all be tried ("View as" in the menu
|
The dev user is `PLATFORM_ADMIN`, so user, auditor and admin pages can all be tried ("View as" in the menu
|
||||||
@@ -386,6 +441,17 @@ npm run dev
|
|||||||
Values for development and production live in `.env.development` and `.env.production`. They end up in the
|
Values for development and production live in `.env.development` and `.env.production`. They end up in the
|
||||||
browser bundle, so they must never hold a secret.
|
browser bundle, so they must never hold a secret.
|
||||||
|
|
||||||
|
Production image (container env, read by nginx at start):
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `AUTH_HOST` | Auth host name for the CSP `connect-src` |
|
||||||
|
| `API_HOST` | Core host name, same |
|
||||||
|
|
||||||
|
The image refuses to start when either is unset or empty (`docker-entrypoint.d/10-require-hosts.sh`);
|
||||||
|
`docker-compose.yml` of the Deployment repo passes both. `FRONTEND_HOST` is not
|
||||||
|
passed to the frontend image; compose uses it for the Traefik rule and as `APP_FRONTEND_URL` of Auth and Core (Deployment `docker-compose.yml`:68,152).
|
||||||
|
|
||||||
## Tests and build
|
## Tests and build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Empty hosts would give a CSP connect-src that blocks every API call. Runs before 20-envsubst-on-templates.sh.
|
||||||
|
: "${AUTH_HOST:?set AUTH_HOST (public host name of Auth) in the container env}"
|
||||||
|
: "${API_HOST:?set API_HOST (public host name of Core) in the container env}"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Glossary
|
||||||
|
|
||||||
|
One word per idea in all German (de) and English (en) texts (`public/i18n/*.json`). `src/i18n.test.js` fails when an
|
||||||
|
old term comes back. A new text uses these words; a new idea gets a row here first.
|
||||||
|
|
||||||
|
| Concept | de | en |
|
||||||
|
|---|---|---|
|
||||||
|
| finish a game (status FINISHED) | durchspielen, durchgespielt (never "beenden", "abgeschlossen", "Beender") | finish, finished |
|
||||||
|
| 100 % status (COMPLETED) | 100 % | 100% (no space) |
|
||||||
|
| proof of play (rewards) | Nachweis, nachgewiesen (never "Beweis", "frisches Spielen") | proof, proven; "recent play" (never "fresh play") |
|
||||||
|
| earn points | Punkte bringen / es gibt Punkte (never "zahlen") | earn points (never "pay") |
|
||||||
|
| points booked to the wallet | gutgeschrieben | credited |
|
||||||
|
| currency / page name | Pile Points | Pile Points |
|
||||||
|
| pile (games with status BACKLOG) | **Pile of Shame** (proper name, also in German, masculine; never "Stapel") | Pile of Shame; "pile" only inside "Pile of Shame" |
|
||||||
|
| status BACKLOG | Backlog | Backlog |
|
||||||
|
| `/backlog` tab and dashboard tile | Warteschlange | Queue |
|
||||||
|
| `/play-next` | Als Nächstes | Play next |
|
||||||
|
| game page card (priority, notes) | Mein Fortschritt | My progress |
|
||||||
|
| find games page (`/search`) | Spiele finden | Find games |
|
||||||
|
| library search box | In deiner Bibliothek suchen | Search your library |
|
||||||
|
| sync | der Sync; button "Sync starten"; "Sync läuft …"; "Letzter Sync {{when}}" (never "synchronisieren", "Synchronisierung") | sync; "Sync now"; "Syncing …"; "Last sync {{when}}" |
|
||||||
|
| retry | button "Noch einmal versuchen"; sentence "Versuche es noch einmal." / "… später noch einmal." (never "erneut") | button "Try again"; sentence "Try again." / "Try again later." (never "Please try again") |
|
||||||
|
| link queue (auditors) | Verknüpfungen (never "Warteschlange") | Link queue |
|
||||||
|
| notifications (bell) | Postfach, Nachricht | Inbox, message |
|
||||||
|
| people you follow (list, tile, tab) | Ich folge (never "Folgen" as a noun); the button stays "Folgen" | Following |
|
||||||
|
| community page | Community | Community |
|
||||||
|
| playtime (played hours) | Spielzeit | playtime (one word) |
|
||||||
|
| time to beat (IGDB) | Spieldauer (never "Spielzeit") | time to beat |
|
||||||
|
| status history (V14 log) | Status-Verlauf | status history |
|
||||||
|
| edition of one game | Edition, Editionen (never "Ausgabe") | edition |
|
||||||
|
| link / unlink an account (Steam, PSN) | verknüpfen / trennen | link / unlink |
|
||||||
|
| link games as one (auditor) | verknüpfen / Verknüpfung lösen | link / unlink |
|
||||||
|
| hide a game | ausblenden | hide |
|
||||||
|
| hide a tile | verstecken | hide |
|
||||||
|
| user | Nutzer (never "Spieler", "Person" for app users) | user |
|
||||||
|
| auditor role | Auditor | auditor |
|
||||||
|
| inbox entry | Nachricht | message (never "note": notes are game notes) |
|
||||||
|
| email | E-Mail (labels), Mail (in sentences ok) | email (never "a mail", "mails") |
|
||||||
|
| log in / out | anmelden / abmelden | log in / log out (verbs on buttons) |
|
||||||
|
| manual game | manuelles Spiel | manual game |
|
||||||
|
| address form | informal "du", imperative ("Versuche es …"); no impersonal "Bitte … versuchen" | – |
|
||||||
|
| quotes | „…“ | "…" |
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# README screenshots
|
||||||
|
|
||||||
|
The screenshots in the main README show a neutral demo library (30 games, persona "Dev"), never real user data.
|
||||||
|
They come from the local stack with the WireMock mocks of `ToBePlayed-Core/local/wiremock`.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
1. Start the stack as in the main README, section "Local development without keys".
|
||||||
|
2. Open the app in a desktop browser, log in with `dev@localhost` / `password123`, pass the welcome page.
|
||||||
|
3. Settings: dark mode on, language English, public profile on.
|
||||||
|
4. Dashboard: "Sync now". Wait about one minute: the achievements load one Steam call per second.
|
||||||
|
5. Set three things by hand (the sync cannot):
|
||||||
|
- No Man's Sky: status Dropped.
|
||||||
|
- Slay the Spire: "Set to 100%".
|
||||||
|
- Celeste: rating 4 stars, note "Chapter 7 next."
|
||||||
|
6. Reload the dashboard and close the "games imported" banner.
|
||||||
|
|
||||||
|
## Capture
|
||||||
|
|
||||||
|
Set the viewport (browser DevTools device toolbar, or Playwright) to the size in the table. Take a viewport PNG, no
|
||||||
|
scaling, not full page.
|
||||||
|
|
||||||
|
| File | Viewport | Page and state |
|
||||||
|
|---|---|---|
|
||||||
|
| `library-desktop-dark.png` | 1280x1022 | `/library`, filter All, sort "Most played", 20 per page |
|
||||||
|
| `detail-desktop-dark.png` | 1280x1414 | `/library/504230` (Celeste) |
|
||||||
|
| `dashboard-desktop-dark.png` | 1280x1100 | `/dashboard`, banner closed, mouse away from the cards |
|
||||||
|
| `library-mobile-dark.png` | 390x1036 | `/library`, same state as desktop |
|
||||||
|
| `profile-desktop-dark.png` | 1280x1288 | log out, then `/u/Dev` |
|
||||||
|
|
||||||
|
## From the Claude box
|
||||||
|
|
||||||
|
The box is itself a container, so the stack differs from the main README. Traps first: the WireMock `-v` path is
|
||||||
|
resolved on the Docker host (the mock then loads 0 mappings), and the box reaches sibling containers only through
|
||||||
|
the shared network, not through `localhost`.
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -d --name tbp_auth_db_local -e POSTGRES_DB=tbp_auth -e POSTGRES_USER=tbp_user -e POSTGRES_PASSWORD=local_secret_password postgres:17-alpine
|
||||||
|
docker run -d --name tbp_core_db_local -e POSTGRES_DB=tbp_core -e POSTGRES_USER=tbp_user -e POSTGRES_PASSWORD=local_secret_password postgres:17-alpine
|
||||||
|
docker run -d --name tbp_core_mocks_local wiremock/wiremock:3.9.1
|
||||||
|
for c in tbp_auth_db_local tbp_core_db_local tbp_core_mocks_local; do docker network connect ai_default $c; done
|
||||||
|
docker cp /workspace/private/tbp/ToBePlayed-Core/local/wiremock/. tbp_core_mocks_local:/home/wiremock/
|
||||||
|
curl -X POST tbp_core_mocks_local:8080/__admin/mappings/reset
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat the `docker cp` and the reset after every mock change. Run the built jars (`target/`) and Vite:
|
||||||
|
|
||||||
|
- Auth: `java -jar target/auth-service-0.0.1-SNAPSHOT.jar --spring.profiles.active=local --spring.docker.compose.enabled=false`
|
||||||
|
plus `--spring.datasource.url=jdbc:postgresql://tbp_auth_db_local:5432/tbp_auth`, `--spring.datasource.username=tbp_user`
|
||||||
|
and `--spring.datasource.password=local_secret_password`.
|
||||||
|
- Core: the same with `tbp_core_db_local` and `tbp_core`, plus `--app.steam.base-url=$M/steam`,
|
||||||
|
`--app.igdb.base-url=$M/igdb`, `--app.igdb.token-base-url=$M/twitch`, `--app.psn.auth-base-url=$M/psn/auth`,
|
||||||
|
`--app.psn.api-base-url=$M/psn` and `--app.speedrun.base-url=$M/speedrun`. `M` is `http://<mock container IP>:8080`
|
||||||
|
(`docker inspect tbp_core_mocks_local`): Java rejects the underscore in the container name.
|
||||||
|
- Frontend: `VITE_API_URL=http://localhost:8080 VITE_CORE_API_URL=http://localhost:8090 npx vite --port 5173`.
|
||||||
|
|
||||||
|
When done, stop the three processes and `docker rm -f` the three containers.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Only the demo persona may appear: name "Dev", Steam ID `76561198000000001`, the default avatar.
|
||||||
|
- Keep the file names. Keep each file below about 500 KB.
|
||||||
|
- Look at every image before you commit it.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
The 30 games, their achievements and the profile are in `ToBePlayed-Core/local/wiremock`.
|
||||||
|
`LocalMocksParseTest` checks that the files fit the Core types and that every achievement stub belongs to an owned game.
|
||||||
|
After Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 458 KiB |
@@ -11,14 +11,17 @@
|
|||||||
"SUBMIT": "Spiel betreten",
|
"SUBMIT": "Spiel betreten",
|
||||||
"NO_ACCOUNT": "Noch kein Konto?",
|
"NO_ACCOUNT": "Noch kein Konto?",
|
||||||
"FORGOT_PASSWORD": "Passwort vergessen?",
|
"FORGOT_PASSWORD": "Passwort vergessen?",
|
||||||
"ERROR_EMAIL": "Gib eine gültige E-Mail-Adresse mit „@“ und „.“ ein.",
|
"ERROR_EMAIL": "Gib eine E-Mail-Adresse mit „@“ ein.",
|
||||||
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein.",
|
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein.",
|
||||||
"ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.",
|
"ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.",
|
||||||
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
|
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
|
||||||
"ERROR_GENERIC": "Anmeldung fehlgeschlagen. Versuche es später noch einmal.",
|
"ERROR_GENERIC": "Anmeldung fehlgeschlagen. Versuche es später noch einmal.",
|
||||||
"SESSION_EXPIRED": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
|
"SESSION_EXPIRED": "Deine Sitzung ist abgelaufen. Bitte melde dich noch einmal an.",
|
||||||
"ERROR_ACCOUNT_BLOCKED": "Dieses Konto ist gesperrt. Bitte wende dich an den Admin.",
|
"ERROR_ACCOUNT_BLOCKED": "Dieses Konto ist gesperrt. Bitte wende dich an den Admin.",
|
||||||
"ERROR_ACCOUNT_PENDING": "Deine Registrierung wartet auf den Admin. Du bekommst eine Mail, sobald sie entschieden ist."
|
"ERROR_ACCOUNT_PENDING": "Deine Registrierung wartet auf den Admin. Du bekommst eine Mail, sobald sie entschieden ist.",
|
||||||
|
"NOT_CONFIRMED_HINT": "Noch nicht bestätigt? Lass dir den Link noch einmal senden.",
|
||||||
|
"SHOW_PASSWORD": "Passwort anzeigen",
|
||||||
|
"HIDE_PASSWORD": "Passwort verbergen"
|
||||||
},
|
},
|
||||||
"REGISTRATION": {
|
"REGISTRATION": {
|
||||||
"TITLE": "Registrieren",
|
"TITLE": "Registrieren",
|
||||||
@@ -26,7 +29,8 @@
|
|||||||
"PASSWORD": "Passwort",
|
"PASSWORD": "Passwort",
|
||||||
"PASSWORD_CONFIRMATION": "Passwort bestätigen",
|
"PASSWORD_CONFIRMATION": "Passwort bestätigen",
|
||||||
"SUBMIT": "Konto erstellen",
|
"SUBMIT": "Konto erstellen",
|
||||||
"SUCCESS": "Wir haben dir eine Mail geschickt. Klick auf den Link darin, um deine Adresse zu bestätigen und dein Passwort zu wählen; erst dann sieht der Admin deine Anfrage.",
|
"SENT_TO": "Wir haben den Link an {{email}} geschickt.",
|
||||||
|
"START_OVER": "Falsche Adresse? Neu beginnen",
|
||||||
"RESEND_EMAIL": "E-Mail",
|
"RESEND_EMAIL": "E-Mail",
|
||||||
"RESEND": "Link noch einmal senden",
|
"RESEND": "Link noch einmal senden",
|
||||||
"RESEND_SENT": "Wenn für diese Adresse eine unbestätigte Registrierung besteht, ist ein neuer Link unterwegs.",
|
"RESEND_SENT": "Wenn für diese Adresse eine unbestätigte Registrierung besteht, ist ein neuer Link unterwegs.",
|
||||||
@@ -37,7 +41,7 @@
|
|||||||
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
|
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
|
||||||
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
|
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
|
||||||
"INTRO": "Du bekommst eine Mail mit einem Link: dort bestätigst du deine Adresse und wählst dein Passwort. Danach prüft ein Admin dein Konto und du bekommst eine Mail mit der Entscheidung.",
|
"INTRO": "Du bekommst eine Mail mit einem Link: dort bestätigst du deine Adresse und wählst dein Passwort. Danach prüft ein Admin dein Konto und du bekommst eine Mail mit der Entscheidung.",
|
||||||
"ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal."
|
"ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Versuche es später noch einmal."
|
||||||
},
|
},
|
||||||
"RESET_PASSWORD": {
|
"RESET_PASSWORD": {
|
||||||
"TITLE": "Passwort zurücksetzen",
|
"TITLE": "Passwort zurücksetzen",
|
||||||
@@ -49,16 +53,17 @@
|
|||||||
"DONE": "Dein Passwort ist geändert. Melde dich mit dem neuen an.",
|
"DONE": "Dein Passwort ist geändert. Melde dich mit dem neuen an.",
|
||||||
"TO_LOGIN": "Anmelden",
|
"TO_LOGIN": "Anmelden",
|
||||||
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 1 Stunde. Fordere einen neuen an:",
|
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 1 Stunde. Fordere einen neuen an:",
|
||||||
"ERROR_TOO_MANY": "Zu viele Versuche. Bitte versuche es in {{minutes}} Minuten noch einmal.",
|
"ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es noch einmal.",
|
||||||
"ERROR_GENERIC": "Das hat nicht geklappt. Bitte versuche es noch einmal."
|
"ERROR_TOO_MANY": "Zu viele Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
|
||||||
|
"ERROR_GENERIC": "Das hat nicht geklappt. Versuche es noch einmal."
|
||||||
},
|
},
|
||||||
"CONFIRM_EMAIL": {
|
"CONFIRM_EMAIL": {
|
||||||
"TITLE": "E-Mail-Adresse bestätigen",
|
"TITLE": "E-Mail-Adresse bestätigen",
|
||||||
"INTRO": "Wähle ein Passwort, um deine E-Mail-Adresse zu bestätigen.",
|
"INTRO": "Wähle ein Passwort, um deine E-Mail-Adresse zu bestätigen.",
|
||||||
"SUBMIT": "Bestätigen",
|
"SUBMIT": "Bestätigen",
|
||||||
"DONE": "Deine Adresse ist bestätigt. Ein Admin prüft deine Anfrage; du bekommst eine Mail, sobald entschieden ist.",
|
"DONE": "Deine Adresse ist bestätigt. Du bekommst eine Mail, sobald ein Admin dein Konto freigegeben hat.",
|
||||||
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 24 Stunden. Fordere einen neuen an:",
|
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 24 Stunden. Fordere einen neuen an:",
|
||||||
"TO_LOGIN": "Anmelden"
|
"ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es noch einmal."
|
||||||
},
|
},
|
||||||
"SETTINGS": {
|
"SETTINGS": {
|
||||||
"PROFILE_STYLE": "Aussehen des Profils",
|
"PROFILE_STYLE": "Aussehen des Profils",
|
||||||
@@ -70,13 +75,13 @@
|
|||||||
"TITLE": "Einstellungen",
|
"TITLE": "Einstellungen",
|
||||||
"LOADING": "Einstellungen werden geladen …",
|
"LOADING": "Einstellungen werden geladen …",
|
||||||
"ACCOUNT": "Konto",
|
"ACCOUNT": "Konto",
|
||||||
"PREFERENCES": "Darstellung & Sprache",
|
"PREFERENCES": "Sprache & Plattform",
|
||||||
"STEAM": "Steam",
|
"STEAM": "Steam",
|
||||||
"EMAIL": "E-Mail",
|
"EMAIL": "E-Mail",
|
||||||
"ROLE": "Rolle",
|
"ROLE": "Rolle",
|
||||||
"CREATED_DATE": "Mitglied seit",
|
"CREATED_DATE": "Mitglied seit",
|
||||||
"LAST_LOGIN": "Letzter Login",
|
"LAST_LOGIN": "Letzter Login",
|
||||||
"OPEN_WELCOME": "Willkommensseite erneut anzeigen",
|
"OPEN_WELCOME": "Willkommensseite noch einmal anzeigen",
|
||||||
"DARK_THEME": "Dunkles Design",
|
"DARK_THEME": "Dunkles Design",
|
||||||
"LANGUAGE": "Sprache",
|
"LANGUAGE": "Sprache",
|
||||||
"LANGUAGE_OPTIONS": {
|
"LANGUAGE_OPTIONS": {
|
||||||
@@ -120,7 +125,7 @@
|
|||||||
"REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.",
|
"REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.",
|
||||||
"TILT": "Neigen bewegt Effekte",
|
"TILT": "Neigen bewegt Effekte",
|
||||||
"TILT_HINT": "Hintergründe folgen, wenn du das Handy neigst. Das Handy fragt nach der Erlaubnis.",
|
"TILT_HINT": "Hintergründe folgen, wenn du das Handy neigst. Das Handy fragt nach der Erlaubnis.",
|
||||||
"TILT_DENIED": "Erlaubnis abgelehnt. Erlaube den Bewegungszugriff in Safari und versuche es erneut.",
|
"TILT_DENIED": "Erlaubnis abgelehnt. Erlaube den Bewegungszugriff in Safari und versuche es noch einmal.",
|
||||||
"BACKGROUND": "Hintergrund",
|
"BACKGROUND": "Hintergrund",
|
||||||
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
|
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
|
||||||
"BACKGROUND_REMOVE": "Hintergrund entfernen",
|
"BACKGROUND_REMOVE": "Hintergrund entfernen",
|
||||||
@@ -132,6 +137,10 @@
|
|||||||
"VERIFY": "Mit Steam bestätigen",
|
"VERIFY": "Mit Steam bestätigen",
|
||||||
"CHANGE": "Anderes Steam-Konto verknüpfen",
|
"CHANGE": "Anderes Steam-Konto verknüpfen",
|
||||||
"UNLINK": "Trennen",
|
"UNLINK": "Trennen",
|
||||||
|
"UNLINK_TITLE": "Steam-Konto trennen?",
|
||||||
|
"UNLINK_EXPLAIN": "Der Steam-Sync stoppt. Deine Spiele bleiben in deiner Bibliothek. Für einen neuen Sync verknüpfst du das Konto wieder.",
|
||||||
|
"UNLINK_CONFIRM": "Ja, trennen",
|
||||||
|
"UNLINK_ERROR": "Das Steam-Konto konnte nicht getrennt werden.",
|
||||||
"VERIFIED": "Bestätigt",
|
"VERIFIED": "Bestätigt",
|
||||||
"NOT_VERIFIED": "Nicht bestätigt",
|
"NOT_VERIFIED": "Nicht bestätigt",
|
||||||
"NONE": "Noch kein Steam-Konto verknüpft. Du meldest dich bei Steam an, und Steam nennt uns deine Steam-ID; dein Steam-Passwort sehen wir nie.",
|
"NONE": "Noch kein Steam-Konto verknüpft. Du meldest dich bei Steam an, und Steam nennt uns deine Steam-ID; dein Steam-Passwort sehen wir nie.",
|
||||||
@@ -139,7 +148,7 @@
|
|||||||
"LINKED": "Steam-Konto verknüpft.",
|
"LINKED": "Steam-Konto verknüpft.",
|
||||||
"IN_USE": "Dieses Steam-Konto ist schon mit einem anderen Konto verknüpft.",
|
"IN_USE": "Dieses Steam-Konto ist schon mit einem anderen Konto verknüpft.",
|
||||||
"CANCELLED": "Steam-Verknüpfung abgebrochen.",
|
"CANCELLED": "Steam-Verknüpfung abgebrochen.",
|
||||||
"FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Bitte versuch es noch einmal.",
|
"FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Versuche es noch einmal.",
|
||||||
"ERROR": "Die Steam-Verknüpfung konnte nicht starten.",
|
"ERROR": "Die Steam-Verknüpfung konnte nicht starten.",
|
||||||
"UNLINKED": "Steam-Konto getrennt."
|
"UNLINKED": "Steam-Konto getrennt."
|
||||||
},
|
},
|
||||||
@@ -159,10 +168,13 @@
|
|||||||
"SYNC_HINT": "Jeder Sync holt neu gespielte Titel. Sichere Treffer werden hinzugefügt, unsichere warten in der Import-Prüfung.",
|
"SYNC_HINT": "Jeder Sync holt neu gespielte Titel. Sichere Treffer werden hinzugefügt, unsichere warten in der Import-Prüfung.",
|
||||||
"REVIEW": "Wartende Titel prüfen",
|
"REVIEW": "Wartende Titel prüfen",
|
||||||
"UNLINK": "Trennen",
|
"UNLINK": "Trennen",
|
||||||
|
"UNLINK_TITLE": "PlayStation-Konto trennen?",
|
||||||
|
"UNLINK_EXPLAIN": "Der PlayStation-Sync stoppt. Importierte Spiele bleiben in deiner Bibliothek. Für einen neuen Sync fügst du einen neuen NPSSO-Code ein.",
|
||||||
|
"UNLINK_CONFIRM": "Ja, trennen",
|
||||||
"UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
|
"UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
|
||||||
"INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
|
"INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
|
||||||
"UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
|
"UNAVAILABLE": "PlayStation hat nicht geantwortet. Versuche es später noch einmal.",
|
||||||
"BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.",
|
"BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es noch einmal.",
|
||||||
"ERROR": "Mit der PlayStation-Verknüpfung ist etwas schiefgelaufen."
|
"ERROR": "Mit der PlayStation-Verknüpfung ist etwas schiefgelaufen."
|
||||||
},
|
},
|
||||||
"VERSION": "Version",
|
"VERSION": "Version",
|
||||||
@@ -197,13 +209,21 @@
|
|||||||
"FAVORITE_PLATFORM": "Lieblingsplattform",
|
"FAVORITE_PLATFORM": "Lieblingsplattform",
|
||||||
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
|
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
|
||||||
"FAVORITE_PLATFORM_NONE": "Keine",
|
"FAVORITE_PLATFORM_NONE": "Keine",
|
||||||
"SKIN_OVERRIDES": "Dein Skin legt Farben und Ecken fest. Leg ihn auf der Seite Pile Points ab."
|
"SKIN_OVERRIDES": "Dein Skin legt Farben und Ecken fest. Leg ihn auf der Seite Pile Points ab.",
|
||||||
|
"PROFILE_STYLE_PRIVATE": "Dein Profil ist privat. Schalte es oben öffentlich, dann kannst du das Aussehen ändern.",
|
||||||
|
"TAB_ACCOUNT": "Konto",
|
||||||
|
"TAB_APPEARANCE": "Aussehen",
|
||||||
|
"TAB_CONNECTIONS": "Verbindungen",
|
||||||
|
"TAB_PROFILE": "Profil",
|
||||||
|
"TAB_ABOUT": "Über",
|
||||||
|
"OPEN_COMMUNITY": "Wem du folgst und wer dir folgt, verwaltest du in der Community"
|
||||||
},
|
},
|
||||||
"VALIDATION": {
|
"VALIDATION": {
|
||||||
"EMAIL_REQUIRED": "E-Mail ist erforderlich.",
|
"EMAIL_REQUIRED": "E-Mail ist erforderlich.",
|
||||||
"EMAIL_INVALID": "Gib eine gültige E-Mail-Adresse ein.",
|
"EMAIL_INVALID": "Gib eine gültige E-Mail-Adresse ein.",
|
||||||
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
|
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
|
||||||
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
|
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
|
||||||
|
"PASSWORD_RULES": "Mindestens 8 Zeichen, höchstens 72 (mit Umlauten oder Emoji weniger).",
|
||||||
"PASSWORD_CONFIRMATION_REQUIRED": "Bestätige dein Passwort.",
|
"PASSWORD_CONFIRMATION_REQUIRED": "Bestätige dein Passwort.",
|
||||||
"CURRENT_PASSWORD_REQUIRED": "Gib dein aktuelles Passwort ein.",
|
"CURRENT_PASSWORD_REQUIRED": "Gib dein aktuelles Passwort ein.",
|
||||||
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
|
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
|
||||||
@@ -218,25 +238,32 @@
|
|||||||
"DASHBOARD": {
|
"DASHBOARD": {
|
||||||
"LOADING": "Lade Dashboard ...",
|
"LOADING": "Lade Dashboard ...",
|
||||||
"HELLO": "Hallo",
|
"HELLO": "Hallo",
|
||||||
"STEAM_GAMES": "Steam-Spiele",
|
"GAMES": "Spiele",
|
||||||
"NO_STEAM_ID": "Noch kein Steam-Konto verknüpft – verknüpfe es in den Einstellungen",
|
"NO_STEAM_ID": "Noch kein Steam-Konto verknüpft – verknüpfe es in den Einstellungen",
|
||||||
"MOST_PLAYED": "Meistgespielt",
|
"MOST_PLAYED": "Meistgespielt",
|
||||||
"SHOW_ALL": "Alle anzeigen",
|
"SHOW_ALL": "Alle anzeigen",
|
||||||
"TO_LIBRARY": "Zur Bibliothek",
|
"TO_LIBRARY": "Zur Bibliothek",
|
||||||
"WELCOME_BACK": "Willkommen zurück",
|
"WELCOME_BACK": "Willkommen zurück",
|
||||||
|
"WELCOME": "Willkommen",
|
||||||
|
"EMPTY": {
|
||||||
|
"TITLE": "Deine Bibliothek ist leer",
|
||||||
|
"NO_SOURCE": "Verknüpfe eine Plattform, dann holt der Sync deine Spiele, oder importiere eine Liste.",
|
||||||
|
"LINK": "Steam / PlayStation verknüpfen",
|
||||||
|
"IMPORT": "Spiele importieren"
|
||||||
|
},
|
||||||
"CURRENTLY_PLAYING": "Spiele ich gerade",
|
"CURRENTLY_PLAYING": "Spiele ich gerade",
|
||||||
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
|
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
|
||||||
"UP_NEXT": "Als Nächstes",
|
"UP_NEXT": "Warteschlange",
|
||||||
"UP_NEXT_EMPTY": "Dein Backlog ist leer. Schön!",
|
"UP_NEXT_EMPTY": "Deine Warteschlange ist leer. Schön!",
|
||||||
"PILE_OF_SHAME": "Pile of Shame",
|
"PILE_OF_SHAME": "Pile of Shame",
|
||||||
"PILE_SHARE": "{{percent}} % deiner Bibliothek",
|
"PILE_SHARE": "{{percent}} % deiner Bibliothek",
|
||||||
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
|
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
|
||||||
"NEVER_PLAYED_other": "{{count}} Spiele nie gestartet",
|
"NEVER_PLAYED_other": "{{count}} Spiele nie gestartet",
|
||||||
"SUGGESTION": "Wie wäre es mit",
|
"SUGGESTION": "Wie wäre es mit",
|
||||||
"OTHER_SUGGESTION": "Anderer Vorschlag",
|
"OTHER_SUGGESTION": "Anderer Vorschlag",
|
||||||
"BACKLOG_EMPTY": "Dein Backlog ist leer. Stark!",
|
"BACKLOG_EMPTY": "Dein Pile of Shame ist leer. Stark!",
|
||||||
"SUMMARY_FAILED": "Übersicht konnte nicht geladen werden.",
|
"SUMMARY_FAILED": "Übersicht konnte nicht geladen werden.",
|
||||||
"RETRY": "Erneut versuchen",
|
"RETRY": "Noch einmal versuchen",
|
||||||
"ACTIVITY": "Spielzeit",
|
"ACTIVITY": "Spielzeit",
|
||||||
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
|
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
|
||||||
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
|
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
|
||||||
@@ -260,7 +287,8 @@
|
|||||||
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manuell" },
|
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manuell" },
|
||||||
"TITLE": "Spielebibliothek",
|
"TITLE": "Spielebibliothek",
|
||||||
"LOADING": "Lade Spiele …",
|
"LOADING": "Lade Spiele …",
|
||||||
"EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek oder füge ein Spiel hinzu.",
|
"EMPTY": "Noch keine Spiele. Verknüpfe eine Plattform, importiere eine Liste oder füge ein Spiel hinzu.",
|
||||||
|
"EMPTY_PRIVATE": "Der Sync hat keine Spiele gefunden. Bei Steam müssen die „Spieldetails“ öffentlich sein: [Steam-Datenschutzeinstellungen](https://steamcommunity.com/my/edit/settings). Starte danach den Sync noch einmal.",
|
||||||
"ERROR_LOAD": "Spiele konnten nicht geladen werden.",
|
"ERROR_LOAD": "Spiele konnten nicht geladen werden.",
|
||||||
"COMING": "Erscheint am {{date}}",
|
"COMING": "Erscheint am {{date}}",
|
||||||
"COMING_SOON": "Erscheint bald",
|
"COMING_SOON": "Erscheint bald",
|
||||||
@@ -268,22 +296,28 @@
|
|||||||
"SORT_BY": "Sortieren:",
|
"SORT_BY": "Sortieren:",
|
||||||
"SORT_PLAYTIME_DESC": "Meistgespielt",
|
"SORT_PLAYTIME_DESC": "Meistgespielt",
|
||||||
"SORT_PLAYTIME_ASC": "Am wenigsten gespielt",
|
"SORT_PLAYTIME_ASC": "Am wenigsten gespielt",
|
||||||
"SORT_LAST_SEEN": "Zuletzt synchronisiert",
|
"SORT_NAME": "Name A–Z",
|
||||||
|
"SORT_LAST_PLAYED": "Zuletzt gespielt",
|
||||||
|
"SORT_TIME_TO_BEAT": "Kürzeste Spieldauer",
|
||||||
|
"PAGINATION": "Seiten",
|
||||||
"SORT_FIRST_SEEN": "Neueste in Bibliothek",
|
"SORT_FIRST_SEEN": "Neueste in Bibliothek",
|
||||||
"PREV": "Zurück",
|
"PREV": "Zurück",
|
||||||
"NEXT": "Weiter",
|
"NEXT": "Weiter",
|
||||||
"PAGE_OF": "Seite {{current}} von {{total}}",
|
"PAGE_OF": "Seite {{current}} von {{total}}",
|
||||||
"GAMES": "Spiele",
|
"GAMES": "Spiele",
|
||||||
"RESYNC": "Jetzt synchronisieren",
|
"RESYNC": "Sync starten",
|
||||||
"SYNC_STARTED": "Sync gestartet …",
|
"SYNC_STARTED": "Sync gestartet …",
|
||||||
"SYNC_COOLDOWN": "In {{seconds}}s erneut versuchen",
|
"SYNC_COOLDOWN": "In {{seconds}}s noch einmal versuchen",
|
||||||
"SYNCING": "Synchronisiere mit Steam …",
|
"SYNCING": "Sync läuft …",
|
||||||
"SYNCING_FIRST_TIME": "Erster Sync mit Steam läuft. Deine Spiele erscheinen gleich.",
|
"SYNCING_FIRST_TIME": "Erster Sync läuft. Deine Spiele erscheinen gleich.",
|
||||||
|
"SYNC_QUEUED": "Erster Sync ist eingereiht …",
|
||||||
|
"SYNC_DURATION_HINT": "Meist unter einer Minute, große Bibliotheken länger.",
|
||||||
"SYNC_FAILED": "Sync fehlgeschlagen. Versuche es später noch einmal.",
|
"SYNC_FAILED": "Sync fehlgeschlagen. Versuche es später noch einmal.",
|
||||||
"SYNC_STALE": "Der Sync dauert länger als sonst. Lade die Seite später neu.",
|
"SYNC_STALE": "Der Sync läuft noch. Große Bibliotheken brauchen mehr Zeit. Schau später noch einmal vorbei.",
|
||||||
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
|
"SYNC_NOT_STARTED": "Der erste Sync ist nicht gestartet. Versuche „Sync starten“.",
|
||||||
|
"LAST_SYNCED": "Letzter Sync {{when}}",
|
||||||
"FILTER_ALL": "Alle",
|
"FILTER_ALL": "Alle",
|
||||||
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
"SEARCH_PLACEHOLDER": "In deiner Bibliothek suchen …",
|
||||||
"NO_MATCH": "Keine Spiele für diesen Filter.",
|
"NO_MATCH": "Keine Spiele für diesen Filter.",
|
||||||
"SORT_RATING": "Beste Bewertung",
|
"SORT_RATING": "Beste Bewertung",
|
||||||
"SORT_PRIORITY": "Höchste Priorität",
|
"SORT_PRIORITY": "Höchste Priorität",
|
||||||
@@ -291,9 +325,13 @@
|
|||||||
"FILTER_HIDDEN": "Ausgeblendet",
|
"FILTER_HIDDEN": "Ausgeblendet",
|
||||||
"NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.",
|
"NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.",
|
||||||
"ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen",
|
"ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen",
|
||||||
"EDITIONS": "{{count}} Editionen in deiner Bibliothek"
|
"EDITIONS": "{{count}} Editionen in deiner Bibliothek",
|
||||||
|
"FILTER_REMOVED": "Entfernt",
|
||||||
|
"NO_REMOVED": "Keine entfernten Spiele.",
|
||||||
|
"FIND_GAMES_INSTEAD": "Bei „Spiele finden“ suchen"
|
||||||
},
|
},
|
||||||
"GAME_DETAIL": {
|
"GAME_DETAIL": {
|
||||||
|
"MORE": "Weitere Aktionen",
|
||||||
"BACK": "Zurück",
|
"BACK": "Zurück",
|
||||||
"BACK_TO_LIBRARY": "Zur Bibliothek",
|
"BACK_TO_LIBRARY": "Zur Bibliothek",
|
||||||
"LOADING": "Lade Spiel …",
|
"LOADING": "Lade Spiel …",
|
||||||
@@ -367,6 +405,7 @@
|
|||||||
"WELCOME": "Dein Game-Backlog unter Kontrolle",
|
"WELCOME": "Dein Game-Backlog unter Kontrolle",
|
||||||
"DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.",
|
"DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.",
|
||||||
"GET_STARTED": "Jetzt loslegen",
|
"GET_STARTED": "Jetzt loslegen",
|
||||||
|
"APPROVAL_HINT": "Ein Admin schaltet jedes neue Konto vor der ersten Anmeldung frei.",
|
||||||
"LOGIN": "Anmelden",
|
"LOGIN": "Anmelden",
|
||||||
"LOGOUT": "Abmelden"
|
"LOGOUT": "Abmelden"
|
||||||
},
|
},
|
||||||
@@ -379,7 +418,7 @@
|
|||||||
"LANGUAGE": "Sprache",
|
"LANGUAGE": "Sprache",
|
||||||
"DARK_MODE": "Dunkles Design",
|
"DARK_MODE": "Dunkles Design",
|
||||||
"LIGHT_MODE": "Helles Design",
|
"LIGHT_MODE": "Helles Design",
|
||||||
"PLAY_NEXT": "Nächstes Spiel",
|
"PLAY_NEXT": "Als Nächstes",
|
||||||
"STATS": "Statistik",
|
"STATS": "Statistik",
|
||||||
"COMMUNITY": "Community",
|
"COMMUNITY": "Community",
|
||||||
"POINTS": "Pile Points",
|
"POINTS": "Pile Points",
|
||||||
@@ -393,9 +432,6 @@
|
|||||||
"INBOX": "Postfach",
|
"INBOX": "Postfach",
|
||||||
"INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht",
|
"INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht",
|
||||||
"INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten",
|
"INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten",
|
||||||
"QUESTIONS": "Fragen",
|
|
||||||
"QUESTIONS_OPEN_one": "Fragen, {{count}} offen",
|
|
||||||
"QUESTIONS_OPEN_other": "Fragen, {{count}} offen",
|
|
||||||
"WHATS_NEW": "Neuigkeiten",
|
"WHATS_NEW": "Neuigkeiten",
|
||||||
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
||||||
"ADMIN_BROADCASTS": "Rundmail",
|
"ADMIN_BROADCASTS": "Rundmail",
|
||||||
@@ -403,10 +439,11 @@
|
|||||||
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
|
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
|
||||||
"LINK_QUEUE": "Verknüpfungen",
|
"LINK_QUEUE": "Verknüpfungen",
|
||||||
"AUDIT_LOG": "Protokoll",
|
"AUDIT_LOG": "Protokoll",
|
||||||
"GAME_CATALOG": "Spielekatalog"
|
"GAME_CATALOG": "Spielekatalog",
|
||||||
|
"MY_PROFILE": "Mein öffentliches Profil"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "Mein Backlog",
|
"TITLE": "Mein Fortschritt",
|
||||||
"STATUS_LABEL": "Status",
|
"STATUS_LABEL": "Status",
|
||||||
"PRIORITY_LABEL": "Priorität",
|
"PRIORITY_LABEL": "Priorität",
|
||||||
"RATING_LABEL": "Bewertung",
|
"RATING_LABEL": "Bewertung",
|
||||||
@@ -433,10 +470,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"BACKLOG_ORDER": {
|
"BACKLOG_ORDER": {
|
||||||
"TITLE": "Backlog",
|
"TITLE": "Warteschlange",
|
||||||
"SUBTITLE": "Dein Backlog in deiner eigenen Reihenfolge. Ziehe ein Spiel oder nutze die Pfeile und das Menü. Die ersten Spiele erscheinen unter „Nächstes Spiel“ und auf dem Dashboard.",
|
"SUBTITLE": "Deine Backlog-Spiele in deiner eigenen Reihenfolge. Ziehe ein Spiel oder nutze die Pfeile und das Menü. Die ersten Spiele erscheinen unter „Als Nächstes“ und auf dem Dashboard.",
|
||||||
"EMPTY": "Dein Backlog ist leer. Schön!",
|
"EMPTY": "Deine Warteschlange ist leer. Schön!",
|
||||||
"LOAD_FAILED": "Der Backlog konnte nicht geladen werden.",
|
"LOAD_FAILED": "Die Warteschlange konnte nicht geladen werden.",
|
||||||
"SAVE_FAILED": "Die Reihenfolge konnte nicht gespeichert werden. Die Liste wird neu geladen.",
|
"SAVE_FAILED": "Die Reihenfolge konnte nicht gespeichert werden. Die Liste wird neu geladen.",
|
||||||
"MOVE_UP": "Nach oben",
|
"MOVE_UP": "Nach oben",
|
||||||
"MOVE_DOWN": "Nach unten",
|
"MOVE_DOWN": "Nach unten",
|
||||||
@@ -466,11 +503,17 @@
|
|||||||
"ERROR_LOAD": "Profil konnte nicht geladen werden.",
|
"ERROR_LOAD": "Profil konnte nicht geladen werden.",
|
||||||
"TO_START": "Zur Startseite",
|
"TO_START": "Zur Startseite",
|
||||||
"PERFECT_GAMES": "Perfekte Spiele",
|
"PERFECT_GAMES": "Perfekte Spiele",
|
||||||
"AVERAGE_COMPLETION": "Ø Fortschritt"
|
"AVERAGE_COMPLETION": "Ø Fortschritt",
|
||||||
|
"GAME_NOT_SHOWN": "Dieses Spiel wird in diesem Profil nicht angezeigt.",
|
||||||
|
"GAME_ACHIEVEMENTS": "{{unlocked}} / {{total}} ({{percent}} %)",
|
||||||
|
"OWN_PRIVATE": "Dein Profil ist privat. Mach es in den Einstellungen öffentlich.",
|
||||||
|
"OPEN_SETTINGS": "Einstellungen öffnen",
|
||||||
|
"PUBLIC_CHIP": "Öffentlich",
|
||||||
|
"COPY_LINK": "Link kopieren"
|
||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "Was spiele ich als Nächstes?",
|
"TITLE": "Was spiele ich als Nächstes?",
|
||||||
"SUBTITLE": "Spiele aus deinem Backlog in sieben Reihen: als Nächstes in deiner Reihenfolge, der nächste Teil einer Reihe, die du gespielt hast, ähnlich wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, was Leute gespielt haben, denen du folgst, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.",
|
"SUBTITLE": "Spiele aus deinem Backlog in sieben Reihen: deine Warteschlange, der nächste Teil einer Reihe, die du gespielt hast, ähnlich wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, was Leute gespielt haben, denen du folgst, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.",
|
||||||
"REASON": {
|
"REASON": {
|
||||||
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
||||||
"SERIES": "Reihe: {{name}}",
|
"SERIES": "Reihe: {{name}}",
|
||||||
@@ -480,7 +523,7 @@
|
|||||||
"LONGEST": "In deiner Bibliothek seit {{date}}"
|
"LONGEST": "In deiner Bibliothek seit {{date}}"
|
||||||
},
|
},
|
||||||
"CATEGORY": {
|
"CATEGORY": {
|
||||||
"NEXT_UP": "Als Nächstes",
|
"NEXT_UP": "Warteschlange",
|
||||||
"SERIES": "Nächster Teil der Reihe",
|
"SERIES": "Nächster Teil der Reihe",
|
||||||
"SIMILAR": "Weil du gespielt hast",
|
"SIMILAR": "Weil du gespielt hast",
|
||||||
"STUDIO": "Vom selben Studio",
|
"STUDIO": "Vom selben Studio",
|
||||||
@@ -517,7 +560,7 @@
|
|||||||
"MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.",
|
"MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.",
|
||||||
"MOOD_HINT_SPEEDRUN": "Nur Spiele mit bestätigten Runs auf speedrun.com. Der Backlog wird im Hintergrund geprüft, etwa ein Spiel alle paar Sekunden.",
|
"MOOD_HINT_SPEEDRUN": "Nur Spiele mit bestätigten Runs auf speedrun.com. Der Backlog wird im Hintergrund geprüft, etwa ein Spiel alle paar Sekunden.",
|
||||||
"EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl.",
|
"EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl.",
|
||||||
"EMPTY_SPEEDRUN": "Kein Backlog-Spiel mit Speedruns gefunden. Neue Spiele werden eventuell noch im Hintergrund geprüft; versuch es in ein paar Minuten noch einmal."
|
"EMPTY_SPEEDRUN": "Kein Backlog-Spiel mit Speedruns gefunden. Neue Spiele werden eventuell noch im Hintergrund geprüft; versuche es in ein paar Minuten noch einmal."
|
||||||
},
|
},
|
||||||
"MANUAL_GAME": {
|
"MANUAL_GAME": {
|
||||||
"ADD_BUTTON": "Spiel hinzufügen",
|
"ADD_BUTTON": "Spiel hinzufügen",
|
||||||
@@ -563,7 +606,7 @@
|
|||||||
},
|
},
|
||||||
"PILE": {
|
"PILE": {
|
||||||
"TITLE": "Pile of Shame",
|
"TITLE": "Pile of Shame",
|
||||||
"GAMES": "Spiele auf dem Stapel",
|
"GAMES": "Spiele im Pile of Shame",
|
||||||
"NORMAL": "Zum Durchspielen",
|
"NORMAL": "Zum Durchspielen",
|
||||||
"COMPLETIONIST": "Für 100 %",
|
"COMPLETIONIST": "Für 100 %",
|
||||||
"KNOWN_one": "IGDB kennt {{count}} davon",
|
"KNOWN_one": "IGDB kennt {{count}} davon",
|
||||||
@@ -575,10 +618,12 @@
|
|||||||
"SINCE": "seit {{date}}"
|
"SINCE": "seit {{date}}"
|
||||||
},
|
},
|
||||||
"TREND": {
|
"TREND": {
|
||||||
"TITLE": "Stapel-Verlauf",
|
"TITLE": "Verlauf des Pile of Shame",
|
||||||
"THIS_MONTH": "Diesen Monat:",
|
"THIS_MONTH": "Diesen Monat:",
|
||||||
"THIS_MONTH_ADDED": "+{{added}} neu",
|
"THIS_MONTH_ADDED": "+{{added}} neu",
|
||||||
"THIS_MONTH_DONE": "−{{done}} vom Stapel",
|
"THIS_MONTH_DONE": "−{{done}} aus dem Pile of Shame",
|
||||||
|
"NET_one": "Netto: {{net}} Spiel in 90 Tagen",
|
||||||
|
"NET_other": "Netto: {{net}} Spiele in 90 Tagen",
|
||||||
"STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie."
|
"STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie."
|
||||||
},
|
},
|
||||||
"TOP_GAMES": {
|
"TOP_GAMES": {
|
||||||
@@ -662,14 +707,21 @@
|
|||||||
"IN_LIBRARIES_one": "in {{count}} Bibliothek",
|
"IN_LIBRARIES_one": "in {{count}} Bibliothek",
|
||||||
"IN_LIBRARIES_other": "in {{count}} Bibliotheken",
|
"IN_LIBRARIES_other": "in {{count}} Bibliotheken",
|
||||||
"BEFORE_TRACKING": "Vor Beginn des Status-Verlaufs durchgespielt",
|
"BEFORE_TRACKING": "Vor Beginn des Status-Verlaufs durchgespielt",
|
||||||
"TREND_MOVES": "Stapel-Änderungen der letzten 90 Tage",
|
"TREND_MOVES": "Änderungen im Pile of Shame in den letzten 90 Tagen",
|
||||||
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen",
|
"TREND_ADDED": "Diesen Monat zum Pile of Shame hinzugekommen",
|
||||||
"TREND_DONE": "Diesen Monat vom Stapel gegangen",
|
"TREND_DONE": "Diesen Monat aus dem Pile of Shame gegangen",
|
||||||
"NEW_IN_LIBRARY": "Neu",
|
"NEW_IN_LIBRARY": "Neu",
|
||||||
"GAMES_one": "{{count}} Spiel",
|
"GAMES_one": "{{count}} Spiel",
|
||||||
"GAMES_other": "{{count}} Spiele"
|
"GAMES_other": "{{count}} Spiele"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DIALOG": {
|
||||||
|
"CANCEL": "Abbrechen",
|
||||||
|
"CLOSE": "Schließen"
|
||||||
|
},
|
||||||
|
"NOT_FOUND": {
|
||||||
|
"TITLE": "Seite nicht gefunden"
|
||||||
|
},
|
||||||
"ROLES": {
|
"ROLES": {
|
||||||
"USER": "Nutzer",
|
"USER": "Nutzer",
|
||||||
"AUDITOR": "Auditor",
|
"AUDITOR": "Auditor",
|
||||||
@@ -804,6 +856,10 @@
|
|||||||
"NEVER": "nie"
|
"NEVER": "nie"
|
||||||
},
|
},
|
||||||
"LINKS": {
|
"LINKS": {
|
||||||
|
"SPLIT_CONFIRM_TITLE": "Nicht dasselbe Spiel?",
|
||||||
|
"SPLIT_CONFIRM_TEXT": "Das Spiel wird ein eigener Eintrag in deiner Bibliothek mit eigenem Status, eigener Bewertung und eigenen Notizen. Die Verknüpfung für andere Nutzer bleibt.",
|
||||||
|
"UNLINK_CONFIRM_TITLE": "Für alle trennen?",
|
||||||
|
"UNLINK_CONFIRM_TEXT": "Entfernt die Verknüpfung dieser Spiele für alle Nutzer.",
|
||||||
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
|
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
|
||||||
"SAME_AS": "Dasselbe Spiel wie...",
|
"SAME_AS": "Dasselbe Spiel wie...",
|
||||||
"LINK_WITH": "Verknüpfen mit...",
|
"LINK_WITH": "Verknüpfen mit...",
|
||||||
@@ -838,6 +894,10 @@
|
|||||||
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
|
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GAME_SEARCH": {
|
||||||
|
"TITLE": "Spiele finden",
|
||||||
|
"HINT": "Suche bei IGDB nach dem Namen. Eigene Spiele öffnen deine Spielseite, andere kannst du hinzufügen."
|
||||||
|
},
|
||||||
"CATALOG": {
|
"CATALOG": {
|
||||||
"TITLE": "Spielekatalog",
|
"TITLE": "Spielekatalog",
|
||||||
"SEARCH": "Nach Name suchen",
|
"SEARCH": "Nach Name suchen",
|
||||||
@@ -846,7 +906,9 @@
|
|||||||
"LOAD_ERROR": "Der Spielekatalog konnte nicht geladen werden.",
|
"LOAD_ERROR": "Der Spielekatalog konnte nicht geladen werden.",
|
||||||
"CORRECT": "Korrigieren",
|
"CORRECT": "Korrigieren",
|
||||||
"KEY": "ID {{appid}}",
|
"KEY": "ID {{appid}}",
|
||||||
"GONE": "Dieses Spiel gibt es nicht mehr."
|
"GONE": "Dieses Spiel gibt es nicht mehr.",
|
||||||
|
"NOT_IN_LIBRARY": "Noch nicht in deiner Bibliothek.",
|
||||||
|
"ADD": "Zu meiner Bibliothek hinzufügen"
|
||||||
},
|
},
|
||||||
"AUDIT": {
|
"AUDIT": {
|
||||||
"TITLE": "Protokoll",
|
"TITLE": "Protokoll",
|
||||||
@@ -884,12 +946,12 @@
|
|||||||
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
|
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
|
||||||
"OK": "OK",
|
"OK": "OK",
|
||||||
"SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden.",
|
"SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden.",
|
||||||
"AUTO_NO_STEAM": "[Verknüpfe dein Steam-Konto](/settings), damit deine Bibliothek abgeglichen wird.",
|
"AUTO_NO_STEAM": "[Verknüpfe dein Steam-Konto](/settings?tab=connections), damit deine Bibliothek abgeglichen wird.",
|
||||||
"AUTO_EMPTY_LIBRARY": "Deine Bibliothek ist noch leer. [Importiere deine Spiele](/import).",
|
"AUTO_EMPTY_LIBRARY": "Deine Bibliothek ist noch leer. [Importiere deine Spiele](/import).",
|
||||||
"AUTO_PILE_GREW_one": "Dein Stapel ist diesen Monat um **{{count}}** Spiel gewachsen. [Zum Verlauf](/stats).",
|
"AUTO_PILE_GREW_one": "Dein Pile of Shame ist diesen Monat um **{{count}}** Spiel gewachsen. [Zum Verlauf](/stats).",
|
||||||
"AUTO_PILE_GREW_other": "Dein Stapel ist diesen Monat um **{{count}}** Spiele gewachsen. [Zum Verlauf](/stats).",
|
"AUTO_PILE_GREW_other": "Dein Pile of Shame ist diesen Monat um **{{count}}** Spiele gewachsen. [Zum Verlauf](/stats).",
|
||||||
"AUTO_LINK_QUEUE_one": "**{{count}}** Paar wartet in der [Verknüpfungs-Warteschlange](/audit/links).",
|
"AUTO_LINK_QUEUE_one": "**{{count}}** Paar wartet unter [Verknüpfungen](/audit/links).",
|
||||||
"AUTO_LINK_QUEUE_other": "**{{count}}** Paare warten in der [Verknüpfungs-Warteschlange](/audit/links)."
|
"AUTO_LINK_QUEUE_other": "**{{count}}** Paare warten unter [Verknüpfungen](/audit/links)."
|
||||||
},
|
},
|
||||||
"ADMIN_HINTS": {
|
"ADMIN_HINTS": {
|
||||||
"NEW": "Neuer Hinweis",
|
"NEW": "Neuer Hinweis",
|
||||||
@@ -934,30 +996,45 @@
|
|||||||
"AUTO_TITLE": "Automatische Hinweise",
|
"AUTO_TITLE": "Automatische Hinweise",
|
||||||
"AUTO_NO_STEAM": "Kein Steam-Konto verknüpft",
|
"AUTO_NO_STEAM": "Kein Steam-Konto verknüpft",
|
||||||
"AUTO_EMPTY_LIBRARY": "Leere Bibliothek",
|
"AUTO_EMPTY_LIBRARY": "Leere Bibliothek",
|
||||||
"AUTO_PILE_GREW": "Der Stapel ist diesen Monat gewachsen",
|
"AUTO_PILE_GREW": "Der Pile of Shame ist diesen Monat gewachsen",
|
||||||
"AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)",
|
"AUTO_LINK_QUEUE": "Offene Paare unter Verknüpfungen (Auditoren)",
|
||||||
"AUTO_SAVED": "Gespeichert."
|
"AUTO_SAVED": "Gespeichert."
|
||||||
},
|
},
|
||||||
|
"COMMUNITY": {
|
||||||
|
"TAB_FOLLOWING": "Ich folge",
|
||||||
|
"TAB_FOLLOWERS": "Follower",
|
||||||
|
"TAB_PLAYERS": "Nutzer finden",
|
||||||
|
"TAB_STATS": "Community-Statistik",
|
||||||
|
"SEARCH_PLACEHOLDER": "Nutzer nach Name suchen",
|
||||||
|
"SEARCH_NONE": "Kein öffentliches Profil mit diesem Namen.",
|
||||||
|
"SEARCH_LIMITED": "Zu viele Suchen. Warte eine Minute.",
|
||||||
|
"SEARCH_FAILED": "Die Suche hat nicht geklappt. Versuche es noch einmal.",
|
||||||
|
"SEARCH_HINT": "Nur öffentliche Profile sind zu finden."
|
||||||
|
},
|
||||||
"FOLLOW": {
|
"FOLLOW": {
|
||||||
"TITLE": "Folgen",
|
"TITLE": "Ich folge",
|
||||||
"FOLLOW": "Folgen",
|
"FOLLOW": "Folgen",
|
||||||
"UNFOLLOW": "Entfolgen",
|
"UNFOLLOW": "Entfolgen",
|
||||||
"REMOVE": "Entfernen",
|
"REMOVE": "Entfernen",
|
||||||
"FOLLOWING": "Personen, denen du folgst",
|
|
||||||
"FOLLOWERS": "Personen, die dir folgen",
|
|
||||||
"NO_FOLLOWING": "Du folgst noch niemandem. Öffne ein öffentliches Profil und tippe auf „Folgen“.",
|
"NO_FOLLOWING": "Du folgst noch niemandem. Öffne ein öffentliches Profil und tippe auf „Folgen“.",
|
||||||
"NO_FOLLOWERS": "Dir folgt noch niemand.",
|
"NO_FOLLOWERS": "Dir folgt noch niemand.",
|
||||||
"PRIVATE_USER": "Privater Nutzer",
|
"PRIVATE_USER": "Privater Nutzer",
|
||||||
"NOT_FOLLOWABLE": "Diesem Profil kann man nicht folgen.",
|
"NOT_FOLLOWABLE": "Diesem Profil kann man nicht folgen.",
|
||||||
"LIMIT": "Du folgst schon so vielen Personen wie möglich.",
|
"LIMIT": "Du folgst schon so vielen Personen wie möglich.",
|
||||||
"ERROR": "Das hat nicht geklappt. Versuche es noch einmal.",
|
"ERROR": "Das hat nicht geklappt. Versuche es noch einmal.",
|
||||||
"LOAD_ERROR": "Die Liste konnte nicht geladen werden."
|
"LOAD_ERROR": "Die Liste konnte nicht geladen werden.",
|
||||||
|
"LAST_ACTIVITY": "Letzte Aktivität {{date}}",
|
||||||
|
"NO_ACTIVITY": "Noch keine Aktivität",
|
||||||
|
"SHOW_ALL": "Alle anzeigen",
|
||||||
|
"FIND_PLAYERS": "Nutzer finden"
|
||||||
},
|
},
|
||||||
"INBOX": {
|
"INBOX": {
|
||||||
"TITLE": "Postfach",
|
"TITLE": "Postfach",
|
||||||
"EMPTY": "Noch keine Nachrichten.",
|
"EMPTY": "Noch keine Nachrichten.",
|
||||||
"LOAD_ERROR": "Das Postfach konnte nicht geladen werden.",
|
"LOAD_ERROR": "Das Postfach konnte nicht geladen werden.",
|
||||||
"DIGEST": "{{name}}: {{parts}}",
|
"DIGEST": "{{name}}: {{parts}}",
|
||||||
|
"DIGEST_PARTS": "{{parts}}",
|
||||||
|
"ON_PROFILE": "im Profil",
|
||||||
"VISIBILITY": "{{name}} hat die Sichtbarkeit des Profils geändert, du folgst nicht mehr.",
|
"VISIBILITY": "{{name}} hat die Sichtbarkeit des Profils geändert, du folgst nicht mehr.",
|
||||||
"PSN_EXPIRING": "Deine PlayStation-Verknüpfung endet in etwa 2 Tagen. Füge in den Einstellungen einen neuen NPSSO-Code ein, damit der Sync weiterläuft.",
|
"PSN_EXPIRING": "Deine PlayStation-Verknüpfung endet in etwa 2 Tagen. Füge in den Einstellungen einen neuen NPSSO-Code ein, damit der Sync weiterläuft.",
|
||||||
"PSN_EXPIRED": "Deine PlayStation-Verknüpfung ist beendet. Der Sync überspringt PlayStation, bis du einen neuen NPSSO-Code einfügst.",
|
"PSN_EXPIRED": "Deine PlayStation-Verknüpfung ist beendet. Der Sync überspringt PlayStation, bis du einen neuen NPSSO-Code einfügst.",
|
||||||
@@ -969,12 +1046,20 @@
|
|||||||
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
|
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
|
||||||
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
|
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
|
||||||
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
|
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
|
||||||
"PART_WISHLIST_ADDS_other": "{{count}} Wunschlisten-Einträge"
|
"PART_WISHLIST_ADDS_other": "{{count}} Wunschlisten-Einträge",
|
||||||
|
"DETAIL_NEW_GAME": "Neue Spiele",
|
||||||
|
"DETAIL_ACHIEVEMENT": "Erfolge und Trophäen",
|
||||||
|
"DETAIL_STATUS": "Statusänderungen",
|
||||||
|
"DETAIL_WISHLIST": "Wunschliste",
|
||||||
|
"SHOW_ALL": "Alle {{count}} anzeigen",
|
||||||
|
"DETAILS_GONE": "Keine Details mehr: du folgst dieser Person nicht mehr oder der Bereich ist jetzt privat.",
|
||||||
|
"DETAILS_ERROR": "Die Details konnten nicht geladen werden.",
|
||||||
|
"QUESTIONS_OPEN_one": "{{count}} offene Frage",
|
||||||
|
"QUESTIONS_OPEN_other": "{{count}} offene Fragen",
|
||||||
|
"ALL_NEWS": "Alle Neuigkeiten"
|
||||||
},
|
},
|
||||||
"NEWS": {
|
"NEWS": {
|
||||||
"TITLE": "Neuigkeiten",
|
"TITLE": "Neuigkeiten",
|
||||||
"GOT_IT": "Verstanden",
|
|
||||||
"SAVE_FAILED": "Konnte nicht speichern, dass du die Neuigkeiten gelesen hast.",
|
|
||||||
"LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.",
|
"LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.",
|
||||||
"EMPTY": "Noch nichts Neues."
|
"EMPTY": "Noch nichts Neues."
|
||||||
},
|
},
|
||||||
@@ -986,7 +1071,7 @@
|
|||||||
"FEATURE_STATUS": "Status",
|
"FEATURE_STATUS": "Status",
|
||||||
"FEATURE_STATUS_TEXT": "Markiere ein Spiel als „Spiele ich“, „Durchgespielt“, „100 %“ oder „Abgebrochen“, mit Bewertung und Notizen.",
|
"FEATURE_STATUS_TEXT": "Markiere ein Spiel als „Spiele ich“, „Durchgespielt“, „100 %“ oder „Abgebrochen“, mit Bewertung und Notizen.",
|
||||||
"FEATURE_STATS": "Deine Statistiken",
|
"FEATURE_STATS": "Deine Statistiken",
|
||||||
"FEATURE_STATS_TEXT": "Summen, der Pile in Stunden, meistgespielte Spiele, Genres und durchgespielte Spiele pro Jahr.",
|
"FEATURE_STATS_TEXT": "Summen, der Pile of Shame in Stunden, meistgespielte Spiele, Genres und durchgespielte Spiele pro Jahr.",
|
||||||
"FEATURE_PROFILE": "Öffentliches Profil",
|
"FEATURE_PROFILE": "Öffentliches Profil",
|
||||||
"FEATURE_PROFILE_TEXT": "Teile deine Statistiken und was du spielst per Link. Aus, bis du es einschaltest.",
|
"FEATURE_PROFILE_TEXT": "Teile deine Statistiken und was du spielst per Link. Aus, bis du es einschaltest.",
|
||||||
"FEATURE_IMPORT": "Import",
|
"FEATURE_IMPORT": "Import",
|
||||||
@@ -998,8 +1083,9 @@
|
|||||||
"STEP_STEAM": "Steam-Konto verknüpfen",
|
"STEP_STEAM": "Steam-Konto verknüpfen",
|
||||||
"STEP_IMPORT": "Deine Spiele importieren",
|
"STEP_IMPORT": "Deine Spiele importieren",
|
||||||
"DONE": "Erledigt",
|
"DONE": "Erledigt",
|
||||||
"GO": "Los geht's",
|
"GO": "Zum Dashboard",
|
||||||
"SKIP": "Überspringen"
|
"ACTION_STEAM": "Steam verknüpfen",
|
||||||
|
"ACTION_IMPORT": "Spiele importieren"
|
||||||
},
|
},
|
||||||
"ADMIN_NEWS": {
|
"ADMIN_NEWS": {
|
||||||
"NEW": "Neuer Eintrag",
|
"NEW": "Neuer Eintrag",
|
||||||
@@ -1148,7 +1234,7 @@
|
|||||||
"DONE": "Fertig: {{added}} Spiele hinzugefügt, {{skipped}} übersprungen.",
|
"DONE": "Fertig: {{added}} Spiele hinzugefügt, {{skipped}} übersprungen.",
|
||||||
"FAILED_one": "Der Import ist nach {{count}} Versuch stehen geblieben.",
|
"FAILED_one": "Der Import ist nach {{count}} Versuch stehen geblieben.",
|
||||||
"FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.",
|
"FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.",
|
||||||
"RETRY": "Nochmal versuchen",
|
"RETRY": "Noch einmal versuchen",
|
||||||
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
|
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
|
||||||
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter.",
|
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter.",
|
||||||
"SOURCE_GALAXY": "GOG Galaxy",
|
"SOURCE_GALAXY": "GOG Galaxy",
|
||||||
@@ -1176,15 +1262,15 @@
|
|||||||
"EQUIP": "Anlegen",
|
"EQUIP": "Anlegen",
|
||||||
"UNEQUIP": "Ablegen",
|
"UNEQUIP": "Ablegen",
|
||||||
"EQUIPPED": "Angelegt",
|
"EQUIPPED": "Angelegt",
|
||||||
"EMPTY_HISTORY": "Noch keine Punkte. Spiel etwas von deinem Stapel!",
|
"EMPTY_HISTORY": "Noch keine Punkte. Spiel etwas aus deinem Pile of Shame!",
|
||||||
"ERROR_NOT_ENOUGH": "Nicht genug Punkte.",
|
"ERROR_NOT_ENOUGH": "Nicht genug Punkte.",
|
||||||
"HOW_TO": "So bekommst du Punkte",
|
"HOW_TO": "So bekommst du Punkte",
|
||||||
"RULES": {
|
"RULES": {
|
||||||
"STARTED": "5 Punkte, wenn du ein Spiel von deinem Stapel startest und spielst (Steam-Spiele, einmal pro Spiel).",
|
"STARTED": "5 Punkte, wenn du ein Spiel aus deinem Pile of Shame startest und spielst (Steam-Spiele, einmal pro Spiel).",
|
||||||
"FINISHED": "30, 50 oder 80 Punkte (je nach Spieldauer), wenn du ein Steam-Spiel durchspielst und es einen Nachweis gibt, dass du es gespielt hast. Ohne Nachweis bekommst du sofort 10 Punkte und den Rest, sobald du das Spiel nach dem Durchspielen auf Steam spielst. Ist keine Spielzeit erfasst, gibt es auch erst 10 Punkte, den Rest nur mit Steam-Spielzeit. Einmal pro Spiel.",
|
"FINISHED": "30, 50 oder 80 Punkte (je nach Spieldauer), wenn du ein Steam-Spiel durchspielst und es einen Nachweis gibt, dass du es gespielt hast. Ohne Nachweis bekommst du sofort 10 Punkte und den Rest, sobald du das Spiel nach dem Durchspielen auf Steam spielst. Ist keine Spielzeit erfasst, gibt es auch erst 10 Punkte, den Rest nur mit Steam-Spielzeit. Einmal pro Spiel.",
|
||||||
"COMPLETED": "+40 Punkte, wenn ein Spiel auf 100 % steht und du alle Erfolge freigeschaltet hast. Dafür braucht es ein nachgewiesenes Durchspielen auf Steam (die frühen 10 Punkte reichen erst, wenn der Nachweis da ist) oder Punkte fürs Durchspielen einer manuellen Edition des Spiels, wenn du die 100 % am {{date}} oder später erreicht hast; ein manuelles Spiel selbst bekommt diesen Bonus nie.",
|
"COMPLETED": "+40 Punkte, wenn ein Spiel auf 100 % steht und du alle Erfolge freigeschaltet hast. Dafür braucht es ein nachgewiesenes Durchspielen auf Steam (die frühen 10 Punkte reichen erst, wenn der Nachweis da ist) oder Punkte fürs Durchspielen einer manuellen Edition des Spiels, wenn du die 100 % am {{date}} oder später erreicht hast; ein manuelles Spiel selbst bekommt diesen Bonus nie.",
|
||||||
"FINISHED_MANUAL": "15 Punkte, wenn du ein manuelles Spiel durchspielst. Höchstens 3 durchgespielte Spiele pro Woche (Montag bis Sonntag) bringen Punkte; manuelle Spiele und Steam-Spiele ohne Nachweis teilen sich dieses Limit. Ein 4. manuelles Spiel in dieser Woche bringt keine Punkte; ein 4. Steam-Spiel ohne Nachweis bekommt die vollen Punkte, sobald der Nachweis da ist.",
|
"FINISHED_MANUAL": "15 Punkte, wenn du ein manuelles Spiel durchspielst. Höchstens 3 durchgespielte Spiele pro Woche (Montag bis Sonntag) bringen Punkte; manuelle Spiele und Steam-Spiele ohne Nachweis teilen sich dieses Limit. Ein 4. manuelles Spiel in dieser Woche bringt keine Punkte; ein 4. Steam-Spiel ohne Nachweis bekommt die vollen Punkte, sobald der Nachweis da ist.",
|
||||||
"MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
|
"MONTH": "40 Punkte, wenn dein Pile of Shame am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
|
||||||
"STREAK": "20 Punkte für jede Woche, in der du ein Spiel durchspielst, wenn du auch in der Woche davor eins durchgespielt hast.",
|
"STREAK": "20 Punkte für jede Woche, in der du ein Spiel durchspielst, wenn du auch in der Woche davor eins durchgespielt hast.",
|
||||||
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst.",
|
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst.",
|
||||||
"PROOF": "Als Nachweis für ein Steam-Spiel zählt, dass du es kürzlich gespielt hast: der Erfolg für das Ende der Geschichte (höchstens 30 Tage vor dem Durchspielen freigeschaltet), Spielzeit ab 30 Tagen vor dem Durchspielen oder nach dem Durchspielen die halbe normale Spieldauer gespielt.",
|
"PROOF": "Als Nachweis für ein Steam-Spiel zählt, dass du es kürzlich gespielt hast: der Erfolg für das Ende der Geschichte (höchstens 30 Tage vor dem Durchspielen freigeschaltet), Spielzeit ab 30 Tagen vor dem Durchspielen oder nach dem Durchspielen die halbe normale Spieldauer gespielt.",
|
||||||
@@ -1236,7 +1322,7 @@
|
|||||||
"ITEMS": {
|
"ITEMS": {
|
||||||
"BORDER_GOLD": "Goldener Avatarrahmen",
|
"BORDER_GOLD": "Goldener Avatarrahmen",
|
||||||
"BORDER_NEON": "Neon-Avatarrahmen",
|
"BORDER_NEON": "Neon-Avatarrahmen",
|
||||||
"BADGE_PILE_SLAYER": "Abzeichen Stapel-Bezwinger",
|
"BADGE_PILE_SLAYER": "Abzeichen Pile Slayer",
|
||||||
"BADGE_COMPLETIONIST": "Abzeichen Komplettierer",
|
"BADGE_COMPLETIONIST": "Abzeichen Komplettierer",
|
||||||
"ACCENT_GOLD": "Akzent Gold",
|
"ACCENT_GOLD": "Akzent Gold",
|
||||||
"ACCENT_TEAL": "Akzent Türkis",
|
"ACCENT_TEAL": "Akzent Türkis",
|
||||||
@@ -1299,7 +1385,7 @@
|
|||||||
"FINISHED_EARLY": "Durchgespielt (frühe Punkte)",
|
"FINISHED_EARLY": "Durchgespielt (frühe Punkte)",
|
||||||
"FINISHED_TOPUP": "Durchgespielt (Rest nach Nachweis)",
|
"FINISHED_TOPUP": "Durchgespielt (Rest nach Nachweis)",
|
||||||
"COMPLETED": "100-%-Bonus",
|
"COMPLETED": "100-%-Bonus",
|
||||||
"MONTH": "Kleinerer Stapel im",
|
"MONTH": "Kleinerer Pile of Shame im",
|
||||||
"STREAK": "Serien-Woche",
|
"STREAK": "Serien-Woche",
|
||||||
"STARTER": "Startpunkte",
|
"STARTER": "Startpunkte",
|
||||||
"BUY": "Gekauft",
|
"BUY": "Gekauft",
|
||||||
@@ -1310,9 +1396,12 @@
|
|||||||
"GAMES_FINISHED": "{{count}} / {{target}} Spiele durchgespielt",
|
"GAMES_FINISHED": "{{count}} / {{target}} Spiele durchgespielt",
|
||||||
"GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %",
|
"GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %",
|
||||||
"FINISH_WEEKS": "{{count}} / {{target}} Wochen in Folge mit einem durchgespielten Spiel",
|
"FINISH_WEEKS": "{{count}} / {{target}} Wochen in Folge mit einem durchgespielten Spiel",
|
||||||
"PILE_DOWN_MONTHS": "{{count}} / {{target}} Monate in Folge mit kleinerem Stapel",
|
"PILE_DOWN_MONTHS": "{{count}} / {{target}} Monate in Folge mit kleinerem Pile of Shame",
|
||||||
"PLAY_MORE_MONTHS": "{{count}} / {{target}} Monate in Folge mit mehr Spielzeit als im Monat davor"
|
"PLAY_MORE_MONTHS": "{{count}} / {{target}} Monate in Folge mit mehr Spielzeit als im Monat davor"
|
||||||
}
|
},
|
||||||
|
"SUMMARY_START": "Spiel starten: +5",
|
||||||
|
"SUMMARY_FINISH": "Durchspielen: +30 bis +80, je nach Spieldauer",
|
||||||
|
"SUMMARY_COMPLETED": "100 %: +40. Mit Punkten kaufst du Looks."
|
||||||
},
|
},
|
||||||
"UNSAVED_CHANGES": {
|
"UNSAVED_CHANGES": {
|
||||||
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
|
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
|
||||||
@@ -1346,7 +1435,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"REMOVED_GAMES": {
|
"REMOVED_GAMES": {
|
||||||
"TITLE_COUNT": "Entfernte Spiele ({{count}})",
|
|
||||||
"RESTORE": "Wiederherstellen",
|
"RESTORE": "Wiederherstellen",
|
||||||
"RESTORED": "Spiel wiederhergestellt.",
|
"RESTORED": "Spiel wiederhergestellt.",
|
||||||
"HINT": "Wiederhergestellte Spiele behalten Status, Bewertung und Notizen."
|
"HINT": "Wiederhergestellte Spiele behalten Status, Bewertung und Notizen."
|
||||||
@@ -1361,7 +1449,8 @@
|
|||||||
"SKIP": "Überspringen",
|
"SKIP": "Überspringen",
|
||||||
"START": {
|
"START": {
|
||||||
"ASK": "Angefangen?",
|
"ASK": "Angefangen?",
|
||||||
"REASON": "Du hast {{playtime}} gespielt, das Spiel liegt aber noch im Backlog."
|
"REASON": "Du hast {{playtime}} gespielt, das Spiel liegt aber noch im Backlog.",
|
||||||
|
"POINTS": "+5 Punkte"
|
||||||
},
|
},
|
||||||
"FINISHED": {
|
"FINISHED": {
|
||||||
"ASK": "Durchgespielt?",
|
"ASK": "Durchgespielt?",
|
||||||
@@ -1376,6 +1465,9 @@
|
|||||||
"RATE": {
|
"RATE": {
|
||||||
"ASK": "Bewerten?",
|
"ASK": "Bewerten?",
|
||||||
"REASON": "Du hast es durchgespielt. Wie war es?"
|
"REASON": "Du hast es durchgespielt. Wie war es?"
|
||||||
}
|
},
|
||||||
|
"INTRO": "Fragen entstehen aus deiner Spielzeit und deinen Status. Eine Antwort hält deine Bibliothek aktuell und bringt Punkte. Ein „Nein“ gilt nicht für immer: Die Frage zum Starten oder Durchspielen kommt nach 5 weiteren Spielstunden wieder, die Frage zum Abbrechen nach 90 Tagen ohne Spiel. Eine übersprungene Bewertung kommt nicht wieder.",
|
||||||
|
"POINTS_PENDING": "Punkte je nach Spieldauer",
|
||||||
|
"NO_POINTS": "Keine Punkte, aber deine Bibliothek bleibt aktuell."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,14 +11,17 @@
|
|||||||
"SUBMIT": "Enter the game",
|
"SUBMIT": "Enter the game",
|
||||||
"NO_ACCOUNT": "No account yet?",
|
"NO_ACCOUNT": "No account yet?",
|
||||||
"FORGOT_PASSWORD": "Forgot password?",
|
"FORGOT_PASSWORD": "Forgot password?",
|
||||||
"ERROR_EMAIL": "Enter a valid email address with '@' and '.'.",
|
"ERROR_EMAIL": "Enter an email address with '@'.",
|
||||||
"ERROR_PASSWORD": "The password must not be empty.",
|
"ERROR_PASSWORD": "The password must not be empty.",
|
||||||
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
|
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
|
||||||
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.",
|
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minutes.",
|
||||||
"ERROR_GENERIC": "Login failed. Please try again later.",
|
"ERROR_GENERIC": "Login failed. Try again later.",
|
||||||
"SESSION_EXPIRED": "Your session has expired. Please log in again.",
|
"SESSION_EXPIRED": "Your session has expired. Please log in again.",
|
||||||
"ERROR_ACCOUNT_BLOCKED": "This account is blocked. Please contact the admin.",
|
"ERROR_ACCOUNT_BLOCKED": "This account is blocked. Please contact the admin.",
|
||||||
"ERROR_ACCOUNT_PENDING": "Your registration is waiting for the admin. You will get an email once it is decided."
|
"ERROR_ACCOUNT_PENDING": "Your registration is waiting for the admin. You will get an email once it is decided.",
|
||||||
|
"NOT_CONFIRMED_HINT": "Not confirmed yet? Have the link sent again.",
|
||||||
|
"SHOW_PASSWORD": "Show password",
|
||||||
|
"HIDE_PASSWORD": "Hide password"
|
||||||
},
|
},
|
||||||
"REGISTRATION": {
|
"REGISTRATION": {
|
||||||
"TITLE": "Register",
|
"TITLE": "Register",
|
||||||
@@ -26,18 +29,19 @@
|
|||||||
"PASSWORD": "Password",
|
"PASSWORD": "Password",
|
||||||
"PASSWORD_CONFIRMATION": "Confirm password",
|
"PASSWORD_CONFIRMATION": "Confirm password",
|
||||||
"SUBMIT": "Create account",
|
"SUBMIT": "Create account",
|
||||||
"SUCCESS": "We sent you an email. Click the link in it to confirm your address and choose your password; only then will the admin see your request.",
|
"SENT_TO": "We sent the link to {{email}}.",
|
||||||
|
"START_OVER": "Wrong address? Start over",
|
||||||
"RESEND_EMAIL": "Email",
|
"RESEND_EMAIL": "Email",
|
||||||
"RESEND": "Send the link again",
|
"RESEND": "Send the link again",
|
||||||
"RESEND_SENT": "If this address has an unconfirmed registration, a new link is on its way.",
|
"RESEND_SENT": "If this address has an unconfirmed registration, a new link is on its way.",
|
||||||
"HAVE_ACCOUNT": "Already have an account?",
|
"HAVE_ACCOUNT": "Already have an account?",
|
||||||
"ERROR_GENERIC": "Registration failed. Please try again.",
|
"ERROR_GENERIC": "Registration failed. Try again.",
|
||||||
"ERROR_EMAIL_IN_USE": "This email address is already registered.",
|
"ERROR_EMAIL_IN_USE": "This email address is already registered.",
|
||||||
"DISPLAY_NAME": "Display name (optional)",
|
"DISPLAY_NAME": "Display name (optional)",
|
||||||
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
|
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
|
||||||
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.",
|
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minutes.",
|
||||||
"INTRO": "You will get an email with a link: there you confirm your address and choose your password. Then an admin checks your account and emails you the decision.",
|
"INTRO": "You will get an email with a link: there you confirm your address and choose your password. Then an admin checks your account and emails you the decision.",
|
||||||
"ERROR_PENDING_LIMIT": "Too many registrations are waiting for the admin right now. Please try again later."
|
"ERROR_PENDING_LIMIT": "Too many registrations are waiting for the admin right now. Try again later."
|
||||||
},
|
},
|
||||||
"RESET_PASSWORD": {
|
"RESET_PASSWORD": {
|
||||||
"TITLE": "Reset password",
|
"TITLE": "Reset password",
|
||||||
@@ -49,16 +53,17 @@
|
|||||||
"DONE": "Your password has been changed. Log in with the new one.",
|
"DONE": "Your password has been changed. Log in with the new one.",
|
||||||
"TO_LOGIN": "Log in",
|
"TO_LOGIN": "Log in",
|
||||||
"INVALID": "This link is unknown, already used or older than 1 hour. Request a new one:",
|
"INVALID": "This link is unknown, already used or older than 1 hour. Request a new one:",
|
||||||
"ERROR_TOO_MANY": "Too many attempts. Please try again in {{minutes}} minutes.",
|
"ERROR_REJECTED": "The server did not accept this password. Check the rules and try again.",
|
||||||
"ERROR_GENERIC": "That did not work. Please try again."
|
"ERROR_TOO_MANY": "Too many attempts. Try again in {{minutes}} minutes.",
|
||||||
|
"ERROR_GENERIC": "That did not work. Try again."
|
||||||
},
|
},
|
||||||
"CONFIRM_EMAIL": {
|
"CONFIRM_EMAIL": {
|
||||||
"TITLE": "Confirm your email address",
|
"TITLE": "Confirm your email address",
|
||||||
"INTRO": "Choose a password to confirm your email address.",
|
"INTRO": "Choose a password to confirm your email address.",
|
||||||
"SUBMIT": "Confirm",
|
"SUBMIT": "Confirm",
|
||||||
"DONE": "Your address is confirmed. An admin will check your request; you will get an email once it is decided.",
|
"DONE": "Your address is confirmed. You get a mail once an admin has approved your account.",
|
||||||
"INVALID": "This link is unknown, already used or older than 24 hours. Request a new one:",
|
"INVALID": "This link is unknown, already used or older than 24 hours. Request a new one:",
|
||||||
"TO_LOGIN": "Log in"
|
"ERROR_REJECTED": "The server did not accept this password. Check the rules and try again."
|
||||||
},
|
},
|
||||||
"SETTINGS": {
|
"SETTINGS": {
|
||||||
"PROFILE_STYLE": "Profile look",
|
"PROFILE_STYLE": "Profile look",
|
||||||
@@ -70,7 +75,7 @@
|
|||||||
"TITLE": "Settings",
|
"TITLE": "Settings",
|
||||||
"LOADING": "Loading settings …",
|
"LOADING": "Loading settings …",
|
||||||
"ACCOUNT": "Account",
|
"ACCOUNT": "Account",
|
||||||
"PREFERENCES": "Preferences",
|
"PREFERENCES": "Language & platform",
|
||||||
"STEAM": "Steam",
|
"STEAM": "Steam",
|
||||||
"EMAIL": "Email",
|
"EMAIL": "Email",
|
||||||
"ROLE": "Role",
|
"ROLE": "Role",
|
||||||
@@ -132,6 +137,10 @@
|
|||||||
"VERIFY": "Verify with Steam",
|
"VERIFY": "Verify with Steam",
|
||||||
"CHANGE": "Link another Steam account",
|
"CHANGE": "Link another Steam account",
|
||||||
"UNLINK": "Unlink",
|
"UNLINK": "Unlink",
|
||||||
|
"UNLINK_TITLE": "Unlink Steam account?",
|
||||||
|
"UNLINK_EXPLAIN": "The Steam sync stops. Your games stay in your library. To sync again, link the account again.",
|
||||||
|
"UNLINK_CONFIRM": "Yes, unlink",
|
||||||
|
"UNLINK_ERROR": "Could not unlink the Steam account.",
|
||||||
"VERIFIED": "Verified",
|
"VERIFIED": "Verified",
|
||||||
"NOT_VERIFIED": "Not verified",
|
"NOT_VERIFIED": "Not verified",
|
||||||
"NONE": "No Steam account linked yet. You sign in on Steam, and Steam tells us your Steam ID; we never see your Steam password.",
|
"NONE": "No Steam account linked yet. You sign in on Steam, and Steam tells us your Steam ID; we never see your Steam password.",
|
||||||
@@ -139,7 +148,7 @@
|
|||||||
"LINKED": "Steam account linked.",
|
"LINKED": "Steam account linked.",
|
||||||
"IN_USE": "This Steam account is already linked to another account.",
|
"IN_USE": "This Steam account is already linked to another account.",
|
||||||
"CANCELLED": "Steam link cancelled.",
|
"CANCELLED": "Steam link cancelled.",
|
||||||
"FAILED": "Steam could not confirm the link. Please try again.",
|
"FAILED": "Steam could not confirm the link. Try again.",
|
||||||
"ERROR": "Could not start the Steam link.",
|
"ERROR": "Could not start the Steam link.",
|
||||||
"UNLINKED": "Steam account unlinked."
|
"UNLINKED": "Steam account unlinked."
|
||||||
},
|
},
|
||||||
@@ -159,9 +168,12 @@
|
|||||||
"SYNC_HINT": "Every sync brings in newly played titles. Clear matches are added; unclear ones wait in the import review.",
|
"SYNC_HINT": "Every sync brings in newly played titles. Clear matches are added; unclear ones wait in the import review.",
|
||||||
"REVIEW": "Review waiting titles",
|
"REVIEW": "Review waiting titles",
|
||||||
"UNLINK": "Unlink",
|
"UNLINK": "Unlink",
|
||||||
|
"UNLINK_TITLE": "Unlink PlayStation account?",
|
||||||
|
"UNLINK_EXPLAIN": "The PlayStation sync stops. Imported games stay in your library. To sync again, paste a new NPSSO code.",
|
||||||
|
"UNLINK_CONFIRM": "Yes, unlink",
|
||||||
"UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
|
"UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
|
||||||
"INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
|
"INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
|
||||||
"UNAVAILABLE": "PlayStation did not answer. Please try again later.",
|
"UNAVAILABLE": "PlayStation did not answer. Try again later.",
|
||||||
"BUSY": "Another of your PlayStation requests is still running. Wait a moment and try again.",
|
"BUSY": "Another of your PlayStation requests is still running. Wait a moment and try again.",
|
||||||
"ERROR": "Something went wrong with the PlayStation link."
|
"ERROR": "Something went wrong with the PlayStation link."
|
||||||
},
|
},
|
||||||
@@ -189,21 +201,29 @@
|
|||||||
"CANCEL": "Cancel",
|
"CANCEL": "Cancel",
|
||||||
"DONE": "Your account is deleted.",
|
"DONE": "Your account is deleted.",
|
||||||
"WRONG_PASSWORD": "The password is wrong.",
|
"WRONG_PASSWORD": "The password is wrong.",
|
||||||
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
|
"TOO_MANY": "Too many wrong passwords. Try again in {{minutes}} minutes.",
|
||||||
"ERROR": "The account could not be deleted. Please try again later.",
|
"ERROR": "The account could not be deleted. Try again later.",
|
||||||
"LAST_ADMIN": "You are the last admin. Make another user admin first."
|
"LAST_ADMIN": "You are the last admin. Make another user admin first."
|
||||||
},
|
},
|
||||||
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
|
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
|
||||||
"FAVORITE_PLATFORM": "Favorite platform",
|
"FAVORITE_PLATFORM": "Favorite platform",
|
||||||
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
|
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
|
||||||
"FAVORITE_PLATFORM_NONE": "None",
|
"FAVORITE_PLATFORM_NONE": "None",
|
||||||
"SKIN_OVERRIDES": "Your skin sets colors and corners. Take it off on the Pile Points page."
|
"SKIN_OVERRIDES": "Your skin sets colors and corners. Take it off on the Pile Points page.",
|
||||||
|
"PROFILE_STYLE_PRIVATE": "Your profile is private. Make it public above to change the look.",
|
||||||
|
"TAB_ACCOUNT": "Account",
|
||||||
|
"TAB_APPEARANCE": "Appearance",
|
||||||
|
"TAB_CONNECTIONS": "Connections",
|
||||||
|
"TAB_PROFILE": "Profile",
|
||||||
|
"TAB_ABOUT": "About",
|
||||||
|
"OPEN_COMMUNITY": "Manage who you follow on the Community page"
|
||||||
},
|
},
|
||||||
"VALIDATION": {
|
"VALIDATION": {
|
||||||
"EMAIL_REQUIRED": "Email is required.",
|
"EMAIL_REQUIRED": "Email is required.",
|
||||||
"EMAIL_INVALID": "Enter a valid email address.",
|
"EMAIL_INVALID": "Enter a valid email address.",
|
||||||
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
|
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
|
||||||
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
|
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
|
||||||
|
"PASSWORD_RULES": "At least 8 characters, at most 72 (fewer with umlauts or emoji).",
|
||||||
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
||||||
"CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
|
"CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
|
||||||
"PASSWORD_MISMATCH": "Passwords do not match.",
|
"PASSWORD_MISMATCH": "Passwords do not match.",
|
||||||
@@ -218,23 +238,30 @@
|
|||||||
"DASHBOARD": {
|
"DASHBOARD": {
|
||||||
"LOADING": "Loading dashboard ...",
|
"LOADING": "Loading dashboard ...",
|
||||||
"HELLO": "Hello",
|
"HELLO": "Hello",
|
||||||
"STEAM_GAMES": "Steam games",
|
"GAMES": "Games",
|
||||||
"NO_STEAM_ID": "No Steam account linked yet – link it in the settings",
|
"NO_STEAM_ID": "No Steam account linked yet – link it in the settings",
|
||||||
"MOST_PLAYED": "Most played",
|
"MOST_PLAYED": "Most played",
|
||||||
"SHOW_ALL": "Show all",
|
"SHOW_ALL": "Show all",
|
||||||
"TO_LIBRARY": "Go to library",
|
"TO_LIBRARY": "Go to library",
|
||||||
"WELCOME_BACK": "Welcome back",
|
"WELCOME_BACK": "Welcome back",
|
||||||
|
"WELCOME": "Welcome",
|
||||||
|
"EMPTY": {
|
||||||
|
"TITLE": "Your library is empty",
|
||||||
|
"NO_SOURCE": "Link a platform and we sync your games, or import a list.",
|
||||||
|
"LINK": "Link Steam / PlayStation",
|
||||||
|
"IMPORT": "Import games"
|
||||||
|
},
|
||||||
"CURRENTLY_PLAYING": "Currently playing",
|
"CURRENTLY_PLAYING": "Currently playing",
|
||||||
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
|
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
|
||||||
"UP_NEXT": "Up next",
|
"UP_NEXT": "Queue",
|
||||||
"UP_NEXT_EMPTY": "Your backlog is empty. Nice!",
|
"UP_NEXT_EMPTY": "Your queue is empty. Nice!",
|
||||||
"PILE_OF_SHAME": "Pile of Shame",
|
"PILE_OF_SHAME": "Pile of Shame",
|
||||||
"PILE_SHARE": "{{percent}}% of your library",
|
"PILE_SHARE": "{{percent}}% of your library",
|
||||||
"NEVER_PLAYED_one": "{{count}} game never started",
|
"NEVER_PLAYED_one": "{{count}} game never started",
|
||||||
"NEVER_PLAYED_other": "{{count}} games never started",
|
"NEVER_PLAYED_other": "{{count}} games never started",
|
||||||
"SUGGESTION": "How about",
|
"SUGGESTION": "How about",
|
||||||
"OTHER_SUGGESTION": "Another pick",
|
"OTHER_SUGGESTION": "Another pick",
|
||||||
"BACKLOG_EMPTY": "Your backlog is empty. Nice!",
|
"BACKLOG_EMPTY": "Your Pile of Shame is empty. Nice!",
|
||||||
"SUMMARY_FAILED": "Could not load the overview.",
|
"SUMMARY_FAILED": "Could not load the overview.",
|
||||||
"RETRY": "Try again",
|
"RETRY": "Try again",
|
||||||
"ACTIVITY": "Playtime",
|
"ACTIVITY": "Playtime",
|
||||||
@@ -260,7 +287,8 @@
|
|||||||
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manual" },
|
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manual" },
|
||||||
"TITLE": "Game Library",
|
"TITLE": "Game Library",
|
||||||
"LOADING": "Loading games …",
|
"LOADING": "Loading games …",
|
||||||
"EMPTY": "No games yet. Sync your Steam library or add a game.",
|
"EMPTY": "No games yet. Link a platform, import a list or add a game.",
|
||||||
|
"EMPTY_PRIVATE": "The sync found no games. On Steam, \"Game details\" must be public: [Steam privacy settings](https://steamcommunity.com/my/edit/settings). Then sync again.",
|
||||||
"ERROR_LOAD": "Could not load your games.",
|
"ERROR_LOAD": "Could not load your games.",
|
||||||
"COMING": "Out on {{date}}",
|
"COMING": "Out on {{date}}",
|
||||||
"COMING_SOON": "Coming soon",
|
"COMING_SOON": "Coming soon",
|
||||||
@@ -268,7 +296,10 @@
|
|||||||
"SORT_BY": "Sort by:",
|
"SORT_BY": "Sort by:",
|
||||||
"SORT_PLAYTIME_DESC": "Most played",
|
"SORT_PLAYTIME_DESC": "Most played",
|
||||||
"SORT_PLAYTIME_ASC": "Least played",
|
"SORT_PLAYTIME_ASC": "Least played",
|
||||||
"SORT_LAST_SEEN": "Last synced",
|
"SORT_NAME": "Name A–Z",
|
||||||
|
"SORT_LAST_PLAYED": "Last played",
|
||||||
|
"SORT_TIME_TO_BEAT": "Shortest to beat",
|
||||||
|
"PAGINATION": "Pages",
|
||||||
"SORT_FIRST_SEEN": "Newest in library",
|
"SORT_FIRST_SEEN": "Newest in library",
|
||||||
"PREV": "Previous",
|
"PREV": "Previous",
|
||||||
"NEXT": "Next",
|
"NEXT": "Next",
|
||||||
@@ -277,13 +308,16 @@
|
|||||||
"RESYNC": "Sync now",
|
"RESYNC": "Sync now",
|
||||||
"SYNC_STARTED": "Sync started …",
|
"SYNC_STARTED": "Sync started …",
|
||||||
"SYNC_COOLDOWN": "Try again in {{seconds}}s",
|
"SYNC_COOLDOWN": "Try again in {{seconds}}s",
|
||||||
"SYNCING": "Syncing with Steam …",
|
"SYNCING": "Syncing …",
|
||||||
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
|
"SYNCING_FIRST_TIME": "First sync in progress. Your games will appear here shortly.",
|
||||||
"SYNC_FAILED": "Sync failed. Please try again later.",
|
"SYNC_QUEUED": "First sync queued …",
|
||||||
"SYNC_STALE": "The sync is taking longer than usual. Reload the page later.",
|
"SYNC_DURATION_HINT": "Usually under a minute, large libraries longer.",
|
||||||
"LAST_SYNCED": "Last synced {{when}}",
|
"SYNC_FAILED": "Sync failed. Try again later.",
|
||||||
|
"SYNC_STALE": "The sync is still running. Large libraries need more time. Check back later.",
|
||||||
|
"SYNC_NOT_STARTED": "The first sync did not start. Try \"Sync now\".",
|
||||||
|
"LAST_SYNCED": "Last sync {{when}}",
|
||||||
"FILTER_ALL": "All",
|
"FILTER_ALL": "All",
|
||||||
"SEARCH_PLACEHOLDER": "Search games …",
|
"SEARCH_PLACEHOLDER": "Search your library …",
|
||||||
"NO_MATCH": "No games match this filter.",
|
"NO_MATCH": "No games match this filter.",
|
||||||
"SORT_RATING": "Best rated",
|
"SORT_RATING": "Best rated",
|
||||||
"SORT_PRIORITY": "Highest priority",
|
"SORT_PRIORITY": "Highest priority",
|
||||||
@@ -291,9 +325,13 @@
|
|||||||
"FILTER_HIDDEN": "Hidden",
|
"FILTER_HIDDEN": "Hidden",
|
||||||
"NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.",
|
"NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.",
|
||||||
"ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements",
|
"ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements",
|
||||||
"EDITIONS": "{{count}} editions in your library"
|
"EDITIONS": "{{count}} editions in your library",
|
||||||
|
"FILTER_REMOVED": "Removed",
|
||||||
|
"NO_REMOVED": "No removed games.",
|
||||||
|
"FIND_GAMES_INSTEAD": "Look in Find games"
|
||||||
},
|
},
|
||||||
"GAME_DETAIL": {
|
"GAME_DETAIL": {
|
||||||
|
"MORE": "More actions",
|
||||||
"BACK": "Back",
|
"BACK": "Back",
|
||||||
"BACK_TO_LIBRARY": "Back to library",
|
"BACK_TO_LIBRARY": "Back to library",
|
||||||
"LOADING": "Loading game …",
|
"LOADING": "Loading game …",
|
||||||
@@ -365,8 +403,9 @@
|
|||||||
},
|
},
|
||||||
"MAIN": {
|
"MAIN": {
|
||||||
"WELCOME": "Control your game backlog",
|
"WELCOME": "Control your game backlog",
|
||||||
"DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.",
|
"DESCRIPTION": "Organize your Pile of Shame. Track, manage, and conquer your gaming backlog like never before.",
|
||||||
"GET_STARTED": "Get Started",
|
"GET_STARTED": "Get Started",
|
||||||
|
"APPROVAL_HINT": "An admin approves every new account before the first login.",
|
||||||
"LOGIN": "Log in",
|
"LOGIN": "Log in",
|
||||||
"LOGOUT": "Log out"
|
"LOGOUT": "Log out"
|
||||||
},
|
},
|
||||||
@@ -393,9 +432,6 @@
|
|||||||
"INBOX": "Inbox",
|
"INBOX": "Inbox",
|
||||||
"INBOX_UNREAD_one": "Inbox, {{count}} new message",
|
"INBOX_UNREAD_one": "Inbox, {{count}} new message",
|
||||||
"INBOX_UNREAD_other": "Inbox, {{count}} new messages",
|
"INBOX_UNREAD_other": "Inbox, {{count}} new messages",
|
||||||
"QUESTIONS": "Questions",
|
|
||||||
"QUESTIONS_OPEN_one": "Questions, {{count}} open",
|
|
||||||
"QUESTIONS_OPEN_other": "Questions, {{count}} open",
|
|
||||||
"WHATS_NEW": "What's new",
|
"WHATS_NEW": "What's new",
|
||||||
"ADMIN_NEWS": "Manage news",
|
"ADMIN_NEWS": "Manage news",
|
||||||
"ADMIN_BROADCASTS": "Broadcast email",
|
"ADMIN_BROADCASTS": "Broadcast email",
|
||||||
@@ -403,10 +439,11 @@
|
|||||||
"PENDING_REQUESTS_other": "{{count}} registrations waiting",
|
"PENDING_REQUESTS_other": "{{count}} registrations waiting",
|
||||||
"LINK_QUEUE": "Link queue",
|
"LINK_QUEUE": "Link queue",
|
||||||
"AUDIT_LOG": "Audit log",
|
"AUDIT_LOG": "Audit log",
|
||||||
"GAME_CATALOG": "Game catalog"
|
"GAME_CATALOG": "Game catalog",
|
||||||
|
"MY_PROFILE": "My public profile"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "My backlog",
|
"TITLE": "My progress",
|
||||||
"STATUS_LABEL": "Status",
|
"STATUS_LABEL": "Status",
|
||||||
"PRIORITY_LABEL": "Priority",
|
"PRIORITY_LABEL": "Priority",
|
||||||
"RATING_LABEL": "Rating",
|
"RATING_LABEL": "Rating",
|
||||||
@@ -417,7 +454,7 @@
|
|||||||
"SAVE": "Save",
|
"SAVE": "Save",
|
||||||
"RESET": "Discard",
|
"RESET": "Discard",
|
||||||
"SAVED": "Saved",
|
"SAVED": "Saved",
|
||||||
"SAVE_FAILED": "Saving failed. Please try again.",
|
"SAVE_FAILED": "Saving failed. Try again.",
|
||||||
"STATUS": {
|
"STATUS": {
|
||||||
"BACKLOG": "Backlog",
|
"BACKLOG": "Backlog",
|
||||||
"PLAYING": "Playing",
|
"PLAYING": "Playing",
|
||||||
@@ -433,10 +470,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"BACKLOG_ORDER": {
|
"BACKLOG_ORDER": {
|
||||||
"TITLE": "Backlog",
|
"TITLE": "Queue",
|
||||||
"SUBTITLE": "Your backlog in your own order. Drag a game, or use the arrows and the menu. The first games show up under \"Play next\" and on the dashboard.",
|
"SUBTITLE": "Your backlog games in your own order. Drag a game, or use the arrows and the menu. The first games show up under \"Play next\" and on the dashboard.",
|
||||||
"EMPTY": "Your backlog is empty. Nice!",
|
"EMPTY": "Your queue is empty. Nice!",
|
||||||
"LOAD_FAILED": "Could not load the backlog.",
|
"LOAD_FAILED": "Could not load the queue.",
|
||||||
"SAVE_FAILED": "Could not save the order. Reloading the list.",
|
"SAVE_FAILED": "Could not save the order. Reloading the list.",
|
||||||
"MOVE_UP": "Move up",
|
"MOVE_UP": "Move up",
|
||||||
"MOVE_DOWN": "Move down",
|
"MOVE_DOWN": "Move down",
|
||||||
@@ -466,11 +503,17 @@
|
|||||||
"ERROR_LOAD": "Could not load the profile.",
|
"ERROR_LOAD": "Could not load the profile.",
|
||||||
"TO_START": "Go to the home page",
|
"TO_START": "Go to the home page",
|
||||||
"PERFECT_GAMES": "Perfect games",
|
"PERFECT_GAMES": "Perfect games",
|
||||||
"AVERAGE_COMPLETION": "Avg. completion"
|
"AVERAGE_COMPLETION": "Avg. completion",
|
||||||
|
"GAME_NOT_SHOWN": "This game is not shown on this profile.",
|
||||||
|
"GAME_ACHIEVEMENTS": "{{unlocked}} / {{total}} ({{percent}}%)",
|
||||||
|
"OWN_PRIVATE": "Your profile is private. Make it public in Settings.",
|
||||||
|
"OPEN_SETTINGS": "Open settings",
|
||||||
|
"PUBLIC_CHIP": "Public",
|
||||||
|
"COPY_LINK": "Copy link"
|
||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "What to play next",
|
"TITLE": "What to play next",
|
||||||
"SUBTITLE": "Games from your backlog in seven rows: next in your order, the next part of a series you played, like games you played a lot, from a studio you played, what people you follow played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.",
|
"SUBTITLE": "Games from your backlog in seven rows: your queue, the next part of a series you played, like games you played a lot, from a studio you played, what people you follow played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.",
|
||||||
"REASON": {
|
"REASON": {
|
||||||
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
||||||
"SERIES": "Series: {{name}}",
|
"SERIES": "Series: {{name}}",
|
||||||
@@ -480,7 +523,7 @@
|
|||||||
"LONGEST": "In your library since {{date}}"
|
"LONGEST": "In your library since {{date}}"
|
||||||
},
|
},
|
||||||
"CATEGORY": {
|
"CATEGORY": {
|
||||||
"NEXT_UP": "Up next",
|
"NEXT_UP": "Queue",
|
||||||
"SERIES": "Next in series",
|
"SERIES": "Next in series",
|
||||||
"SIMILAR": "Because you played",
|
"SIMILAR": "Because you played",
|
||||||
"STUDIO": "Same studio",
|
"STUDIO": "Same studio",
|
||||||
@@ -563,7 +606,7 @@
|
|||||||
},
|
},
|
||||||
"PILE": {
|
"PILE": {
|
||||||
"TITLE": "Pile of Shame",
|
"TITLE": "Pile of Shame",
|
||||||
"GAMES": "Games on the pile",
|
"GAMES": "Games in the Pile of Shame",
|
||||||
"NORMAL": "To finish them",
|
"NORMAL": "To finish them",
|
||||||
"COMPLETIONIST": "To get them to 100%",
|
"COMPLETIONIST": "To get them to 100%",
|
||||||
"KNOWN_one": "IGDB knows {{count}} of them",
|
"KNOWN_one": "IGDB knows {{count}} of them",
|
||||||
@@ -575,10 +618,12 @@
|
|||||||
"SINCE": "since {{date}}"
|
"SINCE": "since {{date}}"
|
||||||
},
|
},
|
||||||
"TREND": {
|
"TREND": {
|
||||||
"TITLE": "Pile trend",
|
"TITLE": "Pile of Shame trend",
|
||||||
"THIS_MONTH": "This month:",
|
"THIS_MONTH": "This month:",
|
||||||
"THIS_MONTH_ADDED": "+{{added}} added",
|
"THIS_MONTH_ADDED": "+{{added}} added",
|
||||||
"THIS_MONTH_DONE": "−{{done}} off the pile",
|
"THIS_MONTH_DONE": "−{{done}} off the Pile of Shame",
|
||||||
|
"NET_one": "Net: {{net}} game in 90 days",
|
||||||
|
"NET_other": "Net: {{net}} games in 90 days",
|
||||||
"STARTS": "The trend starts today. Come back tomorrow for the first line."
|
"STARTS": "The trend starts today. Come back tomorrow for the first line."
|
||||||
},
|
},
|
||||||
"TOP_GAMES": {
|
"TOP_GAMES": {
|
||||||
@@ -662,14 +707,21 @@
|
|||||||
"IN_LIBRARIES_one": "in {{count}} library",
|
"IN_LIBRARIES_one": "in {{count}} library",
|
||||||
"IN_LIBRARIES_other": "in {{count}} libraries",
|
"IN_LIBRARIES_other": "in {{count}} libraries",
|
||||||
"BEFORE_TRACKING": "Finished before the status history started",
|
"BEFORE_TRACKING": "Finished before the status history started",
|
||||||
"TREND_MOVES": "Pile moves in the last 90 days",
|
"TREND_MOVES": "Pile of Shame changes in the last 90 days",
|
||||||
"TREND_ADDED": "Added to the pile this month",
|
"TREND_ADDED": "Added to the Pile of Shame this month",
|
||||||
"TREND_DONE": "Off the pile this month",
|
"TREND_DONE": "Off the Pile of Shame this month",
|
||||||
"NEW_IN_LIBRARY": "New",
|
"NEW_IN_LIBRARY": "New",
|
||||||
"GAMES_one": "{{count}} game",
|
"GAMES_one": "{{count}} game",
|
||||||
"GAMES_other": "{{count}} games"
|
"GAMES_other": "{{count}} games"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DIALOG": {
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"CLOSE": "Close"
|
||||||
|
},
|
||||||
|
"NOT_FOUND": {
|
||||||
|
"TITLE": "Page not found"
|
||||||
|
},
|
||||||
"ROLES": {
|
"ROLES": {
|
||||||
"USER": "User",
|
"USER": "User",
|
||||||
"AUDITOR": "Auditor",
|
"AUDITOR": "Auditor",
|
||||||
@@ -804,6 +856,10 @@
|
|||||||
"NEVER": "never"
|
"NEVER": "never"
|
||||||
},
|
},
|
||||||
"LINKS": {
|
"LINKS": {
|
||||||
|
"SPLIT_CONFIRM_TITLE": "Not the same game?",
|
||||||
|
"SPLIT_CONFIRM_TEXT": "The game becomes a separate entry in your library with its own status, rating and notes. The link for other users stays.",
|
||||||
|
"UNLINK_CONFIRM_TITLE": "Unlink for everyone?",
|
||||||
|
"UNLINK_CONFIRM_TEXT": "Removes the link between these games for every user.",
|
||||||
"ALSO_IN_LIBRARY": "Also in your library:",
|
"ALSO_IN_LIBRARY": "Also in your library:",
|
||||||
"SAME_AS": "Same game as...",
|
"SAME_AS": "Same game as...",
|
||||||
"LINK_WITH": "Link with...",
|
"LINK_WITH": "Link with...",
|
||||||
@@ -838,6 +894,10 @@
|
|||||||
"GAME_NOT_FOUND": "This game is not in your library."
|
"GAME_NOT_FOUND": "This game is not in your library."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"GAME_SEARCH": {
|
||||||
|
"TITLE": "Find games",
|
||||||
|
"HINT": "Search IGDB by name. Games you own open your game page; others can be added."
|
||||||
|
},
|
||||||
"CATALOG": {
|
"CATALOG": {
|
||||||
"TITLE": "Game catalog",
|
"TITLE": "Game catalog",
|
||||||
"SEARCH": "Search by name",
|
"SEARCH": "Search by name",
|
||||||
@@ -846,7 +906,9 @@
|
|||||||
"LOAD_ERROR": "The game catalog could not be loaded.",
|
"LOAD_ERROR": "The game catalog could not be loaded.",
|
||||||
"CORRECT": "Correct",
|
"CORRECT": "Correct",
|
||||||
"KEY": "ID {{appid}}",
|
"KEY": "ID {{appid}}",
|
||||||
"GONE": "This game no longer exists."
|
"GONE": "This game no longer exists.",
|
||||||
|
"NOT_IN_LIBRARY": "Not in your library yet.",
|
||||||
|
"ADD": "Add to my library"
|
||||||
},
|
},
|
||||||
"AUDIT": {
|
"AUDIT": {
|
||||||
"TITLE": "Audit log",
|
"TITLE": "Audit log",
|
||||||
@@ -884,10 +946,10 @@
|
|||||||
"DO_NOT_SHOW_AGAIN": "Do not show again",
|
"DO_NOT_SHOW_AGAIN": "Do not show again",
|
||||||
"OK": "OK",
|
"OK": "OK",
|
||||||
"SAVE_FAILED": "The hint could not be saved.",
|
"SAVE_FAILED": "The hint could not be saved.",
|
||||||
"AUTO_NO_STEAM": "[Link your Steam account](/settings) to sync your library.",
|
"AUTO_NO_STEAM": "[Link your Steam account](/settings?tab=connections) to sync your library.",
|
||||||
"AUTO_EMPTY_LIBRARY": "Your library is still empty. [Import your games](/import).",
|
"AUTO_EMPTY_LIBRARY": "Your library is still empty. [Import your games](/import).",
|
||||||
"AUTO_PILE_GREW_one": "Your pile grew by **{{count}}** game this month. [See the trend](/stats).",
|
"AUTO_PILE_GREW_one": "Your Pile of Shame grew by **{{count}}** game this month. [See the trend](/stats).",
|
||||||
"AUTO_PILE_GREW_other": "Your pile grew by **{{count}}** games this month. [See the trend](/stats).",
|
"AUTO_PILE_GREW_other": "Your Pile of Shame grew by **{{count}}** games this month. [See the trend](/stats).",
|
||||||
"AUTO_LINK_QUEUE_one": "**{{count}}** pair is waiting in the [link queue](/audit/links).",
|
"AUTO_LINK_QUEUE_one": "**{{count}}** pair is waiting in the [link queue](/audit/links).",
|
||||||
"AUTO_LINK_QUEUE_other": "**{{count}}** pairs are waiting in the [link queue](/audit/links)."
|
"AUTO_LINK_QUEUE_other": "**{{count}}** pairs are waiting in the [link queue](/audit/links)."
|
||||||
},
|
},
|
||||||
@@ -934,30 +996,45 @@
|
|||||||
"AUTO_TITLE": "Automatic hints",
|
"AUTO_TITLE": "Automatic hints",
|
||||||
"AUTO_NO_STEAM": "No Steam account linked",
|
"AUTO_NO_STEAM": "No Steam account linked",
|
||||||
"AUTO_EMPTY_LIBRARY": "Empty library",
|
"AUTO_EMPTY_LIBRARY": "Empty library",
|
||||||
"AUTO_PILE_GREW": "The pile grew this month",
|
"AUTO_PILE_GREW": "The Pile of Shame grew this month",
|
||||||
"AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)",
|
"AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)",
|
||||||
"AUTO_SAVED": "Saved."
|
"AUTO_SAVED": "Saved."
|
||||||
},
|
},
|
||||||
|
"COMMUNITY": {
|
||||||
|
"TAB_FOLLOWING": "Following",
|
||||||
|
"TAB_FOLLOWERS": "Followers",
|
||||||
|
"TAB_PLAYERS": "Find users",
|
||||||
|
"TAB_STATS": "Community stats",
|
||||||
|
"SEARCH_PLACEHOLDER": "Search users by name",
|
||||||
|
"SEARCH_NONE": "No public profile with this name.",
|
||||||
|
"SEARCH_LIMITED": "Too many searches. Wait a minute.",
|
||||||
|
"SEARCH_FAILED": "The search did not work. Try again.",
|
||||||
|
"SEARCH_HINT": "Only public profiles can be found."
|
||||||
|
},
|
||||||
"FOLLOW": {
|
"FOLLOW": {
|
||||||
"TITLE": "Following",
|
"TITLE": "Following",
|
||||||
"FOLLOW": "Follow",
|
"FOLLOW": "Follow",
|
||||||
"UNFOLLOW": "Unfollow",
|
"UNFOLLOW": "Unfollow",
|
||||||
"REMOVE": "Remove",
|
"REMOVE": "Remove",
|
||||||
"FOLLOWING": "People you follow",
|
|
||||||
"FOLLOWERS": "People who follow you",
|
|
||||||
"NO_FOLLOWING": "You are not following anyone yet. Open a public profile and press \"Follow\".",
|
"NO_FOLLOWING": "You are not following anyone yet. Open a public profile and press \"Follow\".",
|
||||||
"NO_FOLLOWERS": "Nobody follows you yet.",
|
"NO_FOLLOWERS": "Nobody follows you yet.",
|
||||||
"PRIVATE_USER": "Private user",
|
"PRIVATE_USER": "Private user",
|
||||||
"NOT_FOLLOWABLE": "This profile cannot be followed.",
|
"NOT_FOLLOWABLE": "This profile cannot be followed.",
|
||||||
"LIMIT": "You already follow the maximum number of people.",
|
"LIMIT": "You already follow the maximum number of people.",
|
||||||
"ERROR": "That did not work. Try again.",
|
"ERROR": "That did not work. Try again.",
|
||||||
"LOAD_ERROR": "The list could not be loaded."
|
"LOAD_ERROR": "The list could not be loaded.",
|
||||||
|
"LAST_ACTIVITY": "Last activity {{date}}",
|
||||||
|
"NO_ACTIVITY": "No activity yet",
|
||||||
|
"SHOW_ALL": "Show all",
|
||||||
|
"FIND_PLAYERS": "Find users"
|
||||||
},
|
},
|
||||||
"INBOX": {
|
"INBOX": {
|
||||||
"TITLE": "Inbox",
|
"TITLE": "Inbox",
|
||||||
"EMPTY": "No messages yet.",
|
"EMPTY": "No messages yet.",
|
||||||
"LOAD_ERROR": "The inbox could not be loaded.",
|
"LOAD_ERROR": "The inbox could not be loaded.",
|
||||||
"DIGEST": "{{name}}: {{parts}}",
|
"DIGEST": "{{name}}: {{parts}}",
|
||||||
|
"DIGEST_PARTS": "{{parts}}",
|
||||||
|
"ON_PROFILE": "on their profile",
|
||||||
"VISIBILITY": "{{name}} changed their profile visibility, so you no longer follow them.",
|
"VISIBILITY": "{{name}} changed their profile visibility, so you no longer follow them.",
|
||||||
"PSN_EXPIRING": "Your PlayStation link ends in about 2 days. Paste a new NPSSO code in the settings to keep the sync running.",
|
"PSN_EXPIRING": "Your PlayStation link ends in about 2 days. Paste a new NPSSO code in the settings to keep the sync running.",
|
||||||
"PSN_EXPIRED": "Your PlayStation link has ended. The sync skips PlayStation until you paste a new NPSSO code.",
|
"PSN_EXPIRED": "Your PlayStation link has ended. The sync skips PlayStation until you paste a new NPSSO code.",
|
||||||
@@ -969,12 +1046,20 @@
|
|||||||
"PART_STATUS_CHANGES_one": "{{count}} status change",
|
"PART_STATUS_CHANGES_one": "{{count}} status change",
|
||||||
"PART_STATUS_CHANGES_other": "{{count}} status changes",
|
"PART_STATUS_CHANGES_other": "{{count}} status changes",
|
||||||
"PART_WISHLIST_ADDS_one": "{{count}} new wishlist item",
|
"PART_WISHLIST_ADDS_one": "{{count}} new wishlist item",
|
||||||
"PART_WISHLIST_ADDS_other": "{{count}} new wishlist items"
|
"PART_WISHLIST_ADDS_other": "{{count}} new wishlist items",
|
||||||
|
"DETAIL_NEW_GAME": "New games",
|
||||||
|
"DETAIL_ACHIEVEMENT": "Achievements and trophies",
|
||||||
|
"DETAIL_STATUS": "Status changes",
|
||||||
|
"DETAIL_WISHLIST": "Wishlist",
|
||||||
|
"SHOW_ALL": "Show all {{count}}",
|
||||||
|
"DETAILS_GONE": "No details any more: you no longer follow this person or the part is private now.",
|
||||||
|
"DETAILS_ERROR": "The details could not be loaded.",
|
||||||
|
"QUESTIONS_OPEN_one": "{{count}} open question",
|
||||||
|
"QUESTIONS_OPEN_other": "{{count}} open questions",
|
||||||
|
"ALL_NEWS": "All news"
|
||||||
},
|
},
|
||||||
"NEWS": {
|
"NEWS": {
|
||||||
"TITLE": "What's new",
|
"TITLE": "What's new",
|
||||||
"GOT_IT": "Got it",
|
|
||||||
"SAVE_FAILED": "Could not save that you read the news.",
|
|
||||||
"LOAD_ERROR": "The news could not be loaded.",
|
"LOAD_ERROR": "The news could not be loaded.",
|
||||||
"EMPTY": "Nothing new yet."
|
"EMPTY": "Nothing new yet."
|
||||||
},
|
},
|
||||||
@@ -982,11 +1067,11 @@
|
|||||||
"TITLE": "Welcome to ToBePlayed",
|
"TITLE": "Welcome to ToBePlayed",
|
||||||
"INTRO": "ToBePlayed keeps your games in one place: what you own, what you play, what is next.",
|
"INTRO": "ToBePlayed keeps your games in one place: what you own, what you play, what is next.",
|
||||||
"FEATURE_LIBRARY": "Library and Pile of Shame",
|
"FEATURE_LIBRARY": "Library and Pile of Shame",
|
||||||
"FEATURE_LIBRARY_TEXT": "All your games with playtime, filters and search. The pile shows what you never started.",
|
"FEATURE_LIBRARY_TEXT": "All your games with playtime, filters and search. The Pile of Shame shows what you never started.",
|
||||||
"FEATURE_STATUS": "Statuses",
|
"FEATURE_STATUS": "Statuses",
|
||||||
"FEATURE_STATUS_TEXT": "Mark a game as \"Playing\", \"Finished\", \"100%\" or \"Dropped\", with rating and notes.",
|
"FEATURE_STATUS_TEXT": "Mark a game as \"Playing\", \"Finished\", \"100%\" or \"Dropped\", with rating and notes.",
|
||||||
"FEATURE_STATS": "Your stats",
|
"FEATURE_STATS": "Your stats",
|
||||||
"FEATURE_STATS_TEXT": "Totals, the pile in hours, most played games, genres and finished games per year.",
|
"FEATURE_STATS_TEXT": "Totals, the Pile of Shame in hours, most played games, genres and finished games per year.",
|
||||||
"FEATURE_PROFILE": "Public profile",
|
"FEATURE_PROFILE": "Public profile",
|
||||||
"FEATURE_PROFILE_TEXT": "Share your stats and what you play with a link. Off until you switch it on.",
|
"FEATURE_PROFILE_TEXT": "Share your stats and what you play with a link. Off until you switch it on.",
|
||||||
"FEATURE_IMPORT": "Import",
|
"FEATURE_IMPORT": "Import",
|
||||||
@@ -998,8 +1083,9 @@
|
|||||||
"STEP_STEAM": "Link your Steam account",
|
"STEP_STEAM": "Link your Steam account",
|
||||||
"STEP_IMPORT": "Import your games",
|
"STEP_IMPORT": "Import your games",
|
||||||
"DONE": "Done",
|
"DONE": "Done",
|
||||||
"GO": "Let's go",
|
"GO": "Go to dashboard",
|
||||||
"SKIP": "Skip"
|
"ACTION_STEAM": "Link Steam",
|
||||||
|
"ACTION_IMPORT": "Import games"
|
||||||
},
|
},
|
||||||
"ADMIN_NEWS": {
|
"ADMIN_NEWS": {
|
||||||
"NEW": "New entry",
|
"NEW": "New entry",
|
||||||
@@ -1176,15 +1262,15 @@
|
|||||||
"EQUIP": "Equip",
|
"EQUIP": "Equip",
|
||||||
"UNEQUIP": "Take off",
|
"UNEQUIP": "Take off",
|
||||||
"EQUIPPED": "Equipped",
|
"EQUIPPED": "Equipped",
|
||||||
"EMPTY_HISTORY": "No points yet. Play a game from your pile!",
|
"EMPTY_HISTORY": "No points yet. Play a game from your Pile of Shame!",
|
||||||
"ERROR_NOT_ENOUGH": "Not enough points.",
|
"ERROR_NOT_ENOUGH": "Not enough points.",
|
||||||
"HOW_TO": "How to earn points",
|
"HOW_TO": "How to earn points",
|
||||||
"RULES": {
|
"RULES": {
|
||||||
"STARTED": "5 points when you start a game from your pile and play it (Steam games, once per game).",
|
"STARTED": "5 points when you start a game from your Pile of Shame and play it (Steam games, once per game).",
|
||||||
"FINISHED": "30, 50 or 80 points (by time to beat) when you finish a Steam game and there is proof that you played it. Without proof you get 10 points now and the rest once you play the game on Steam after finishing it. With no playtime on record you also get 10 points first, and the rest only with Steam playtime. Once per game.",
|
"FINISHED": "30, 50 or 80 points (by time to beat) when you finish a Steam game and there is proof that you played it. Without proof you get 10 points now and the rest once you play the game on Steam after finishing it. With no playtime on record you also get 10 points first, and the rest only with Steam playtime. Once per game.",
|
||||||
"COMPLETED": "+40 points when a game is at 100% and you unlocked all achievements. It needs a proven Steam finish (the early 10 points count only once proof arrives) or finish points for a manual edition of the game, when you reached 100% on or after {{date}}; a manual game itself never gets this bonus.",
|
"COMPLETED": "+40 points when a game is at 100% and you unlocked all achievements. It needs a proven Steam finish (the early 10 points count only once proof arrives) or finish points for a manual edition of the game, when you reached 100% on or after {{date}}; a manual game itself never gets this bonus.",
|
||||||
"FINISHED_MANUAL": "15 points when you finish a manual game. At most 3 finishes per week (Monday to Sunday) earn points; manual games and Steam games without proof share this limit. A 4th manual finish in that week earns nothing; a 4th Steam finish without proof still earns the full points once proven.",
|
"FINISHED_MANUAL": "15 points when you finish a manual game. At most 3 finishes per week (Monday to Sunday) earn points; manual games and Steam games without proof share this limit. A 4th manual finish in that week earns nothing; a 4th Steam finish without proof still earns the full points once proven.",
|
||||||
"MONTH": "40 points when your pile is smaller at the end of a month than at its start and you finished a game that month.",
|
"MONTH": "40 points when your Pile of Shame is smaller at the end of a month than at its start and you finished a game that month.",
|
||||||
"STREAK": "20 points for every week in which you finish a game, if you also finished one the week before.",
|
"STREAK": "20 points for every week in which you finish a game, if you also finished one the week before.",
|
||||||
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal.",
|
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal.",
|
||||||
"PROOF": "A Steam finish needs proof of recent play: the ending achievement (unlocked at most 30 days before the finish), any playtime from 30 days before the finish on, or half the normal time to beat played after it.",
|
"PROOF": "A Steam finish needs proof of recent play: the ending achievement (unlocked at most 30 days before the finish), any playtime from 30 days before the finish on, or half the normal time to beat played after it.",
|
||||||
@@ -1299,7 +1385,7 @@
|
|||||||
"FINISHED_EARLY": "Finished (early points)",
|
"FINISHED_EARLY": "Finished (early points)",
|
||||||
"FINISHED_TOPUP": "Finished (rest after proof)",
|
"FINISHED_TOPUP": "Finished (rest after proof)",
|
||||||
"COMPLETED": "100% bonus",
|
"COMPLETED": "100% bonus",
|
||||||
"MONTH": "Smaller pile in",
|
"MONTH": "Smaller Pile of Shame in",
|
||||||
"STREAK": "Streak week",
|
"STREAK": "Streak week",
|
||||||
"STARTER": "Starter points",
|
"STARTER": "Starter points",
|
||||||
"BUY": "Bought",
|
"BUY": "Bought",
|
||||||
@@ -1310,9 +1396,12 @@
|
|||||||
"GAMES_FINISHED": "{{count}} / {{target}} games finished",
|
"GAMES_FINISHED": "{{count}} / {{target}} games finished",
|
||||||
"GAMES_COMPLETED": "{{count}} / {{target}} games at 100%",
|
"GAMES_COMPLETED": "{{count}} / {{target}} games at 100%",
|
||||||
"FINISH_WEEKS": "{{count}} / {{target}} weeks in a row with a finished game",
|
"FINISH_WEEKS": "{{count}} / {{target}} weeks in a row with a finished game",
|
||||||
"PILE_DOWN_MONTHS": "{{count}} / {{target}} months in a row with a smaller pile",
|
"PILE_DOWN_MONTHS": "{{count}} / {{target}} months in a row with a smaller Pile of Shame",
|
||||||
"PLAY_MORE_MONTHS": "{{count}} / {{target}} months in a row with more playtime than the month before"
|
"PLAY_MORE_MONTHS": "{{count}} / {{target}} months in a row with more playtime than the month before"
|
||||||
}
|
},
|
||||||
|
"SUMMARY_START": "Start a game: +5",
|
||||||
|
"SUMMARY_FINISH": "Finish a game: +30 to +80 by time to beat",
|
||||||
|
"SUMMARY_COMPLETED": "100%: +40. Points buy looks."
|
||||||
},
|
},
|
||||||
"UNSAVED_CHANGES": {
|
"UNSAVED_CHANGES": {
|
||||||
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
|
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
|
||||||
@@ -1346,7 +1435,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"REMOVED_GAMES": {
|
"REMOVED_GAMES": {
|
||||||
"TITLE_COUNT": "Removed games ({{count}})",
|
|
||||||
"RESTORE": "Restore",
|
"RESTORE": "Restore",
|
||||||
"RESTORED": "Game restored.",
|
"RESTORED": "Game restored.",
|
||||||
"HINT": "Restored games keep their status, rating and notes."
|
"HINT": "Restored games keep their status, rating and notes."
|
||||||
@@ -1361,7 +1449,8 @@
|
|||||||
"SKIP": "Skip",
|
"SKIP": "Skip",
|
||||||
"START": {
|
"START": {
|
||||||
"ASK": "Start it?",
|
"ASK": "Start it?",
|
||||||
"REASON": "You played {{playtime}}, but it is still in your backlog."
|
"REASON": "You played {{playtime}}, but it is still in your backlog.",
|
||||||
|
"POINTS": "+5 points"
|
||||||
},
|
},
|
||||||
"FINISHED": {
|
"FINISHED": {
|
||||||
"ASK": "Finished?",
|
"ASK": "Finished?",
|
||||||
@@ -1376,6 +1465,9 @@
|
|||||||
"RATE": {
|
"RATE": {
|
||||||
"ASK": "Rate it?",
|
"ASK": "Rate it?",
|
||||||
"REASON": "You finished it. How was it?"
|
"REASON": "You finished it. How was it?"
|
||||||
}
|
},
|
||||||
|
"INTRO": "Questions come from your playtime and your statuses. An answer keeps your library current and can earn points. A \"No\" is not forever: a Start or Finished question comes back after 5 more hours of play, a Dropped question after 90 days without play. A skipped rating is not asked again.",
|
||||||
|
"POINTS_PENDING": "Points by time to beat",
|
||||||
|
"NO_POINTS": "No points, but your library stays current."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, Navigate, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {useEffect} from 'react';
|
import {useEffect} from 'react';
|
||||||
|
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import ImprintPage from './pages/ImprintPage';
|
import ImprintPage from './pages/ImprintPage';
|
||||||
|
import NotFoundPage from './pages/NotFoundPage.jsx';
|
||||||
import Dashboard from "./pages/Dashboard.jsx";
|
import Dashboard from "./pages/Dashboard.jsx";
|
||||||
import RegistrationPage from './pages/RegistrationPage.jsx';
|
import RegistrationPage from './pages/RegistrationPage.jsx';
|
||||||
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
|
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
|
||||||
@@ -14,6 +15,7 @@ import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
|||||||
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
||||||
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
||||||
import InboxPage from './pages/InboxPage.jsx';
|
import InboxPage from './pages/InboxPage.jsx';
|
||||||
|
import CommunityPage from './pages/CommunityPage.jsx';
|
||||||
import QuestionsPage from './pages/QuestionsPage.jsx';
|
import QuestionsPage from './pages/QuestionsPage.jsx';
|
||||||
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
||||||
import WelcomePage from './pages/WelcomePage.jsx';
|
import WelcomePage from './pages/WelcomePage.jsx';
|
||||||
@@ -29,10 +31,13 @@ import BacklogPage from './pages/BacklogPage.jsx';
|
|||||||
import PlayNextPage from './pages/PlayNextPage.jsx';
|
import PlayNextPage from './pages/PlayNextPage.jsx';
|
||||||
import StatsPage from './pages/StatsPage.jsx';
|
import StatsPage from './pages/StatsPage.jsx';
|
||||||
import ImportPage from './pages/ImportPage.jsx';
|
import ImportPage from './pages/ImportPage.jsx';
|
||||||
|
import GameSearchPage from './pages/GameSearchPage.jsx';
|
||||||
import PointsPage from './pages/PointsPage.jsx';
|
import PointsPage from './pages/PointsPage.jsx';
|
||||||
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||||
import AppHeader from './components/AppHeader.jsx';
|
import AppHeader from './components/AppHeader.jsx';
|
||||||
|
import BottomNav from './components/BottomNav.jsx';
|
||||||
import RequireSession from './components/RequireSession.jsx';
|
import RequireSession from './components/RequireSession.jsx';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
import {Toaster} from '@/components/ui/sonner';
|
import {Toaster} from '@/components/ui/sonner';
|
||||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||||
import PageBackground from './components/PageBackground.jsx';
|
import PageBackground from './components/PageBackground.jsx';
|
||||||
@@ -44,7 +49,7 @@ import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
|
|||||||
import {logout} from './utils/api.js';
|
import {logout} from './utils/api.js';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {t} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {active: isAuthenticated} = useSession();
|
const {active: isAuthenticated} = useSession();
|
||||||
const {appearance, equipped, syncPreferences} = usePreferences();
|
const {appearance, equipped, syncPreferences} = usePreferences();
|
||||||
|
|
||||||
@@ -52,6 +57,8 @@ function App() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => { document.documentElement.lang = i18n.language; }, [i18n.language]);
|
||||||
|
|
||||||
// On login and on page load; the cached look shows until Auth answers.
|
// On login and on page load; the cached look shows until Auth answers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) syncPreferences();
|
if (isAuthenticated) syncPreferences();
|
||||||
@@ -74,7 +81,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
|
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
|
||||||
|
|
||||||
<main className="grow flex flex-col">
|
<main className={cn('grow flex flex-col', isAuthenticated && 'max-md:pb-16')}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage/>}/>
|
<Route path="/" element={<LandingPage/>}/>
|
||||||
<Route path="/login" element={<LoginPage/>}/>
|
<Route path="/login" element={<LoginPage/>}/>
|
||||||
@@ -85,10 +92,13 @@ function App() {
|
|||||||
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
|
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
|
||||||
<Route path="/library" element={<GameLibrary/>}/>
|
<Route path="/library" element={<GameLibrary/>}/>
|
||||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||||
|
<Route path="/search" element={<GameSearchPage/>}/>
|
||||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||||
<Route path="/backlog" element={<BacklogPage/>}/>
|
<Route path="/backlog" element={<BacklogPage/>}/>
|
||||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||||
<Route path="/stats" element={<StatsPage/>}/>
|
<Route path="/stats" element={<StatsPage/>}/>
|
||||||
|
<Route path="/community" element={<CommunityPage/>}/>
|
||||||
|
<Route path="/following" element={<Navigate to="/community?tab=following" replace/>}/>
|
||||||
<Route path="/import" element={<ImportPage/>}/>
|
<Route path="/import" element={<ImportPage/>}/>
|
||||||
<Route path="/points" element={<PointsPage/>}/>
|
<Route path="/points" element={<PointsPage/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
@@ -106,6 +116,7 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
|
<Route path="*" element={<NotFoundPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -114,9 +125,10 @@ function App() {
|
|||||||
{t('IMPRINT.TITLE')}
|
{t('IMPRINT.TITLE')}
|
||||||
</Link>
|
</Link>
|
||||||
</footer>
|
</footer>
|
||||||
|
{isAuthenticated && <BottomNav/>}
|
||||||
</div>
|
</div>
|
||||||
<UnsavedChangesGuard/>
|
<UnsavedChangesGuard/>
|
||||||
<Toaster richColors position="bottom-right"/>
|
<Toaster richColors position="bottom-right" mobileOffset={{bottom: 72}} className="max-md:![--offset-bottom:72px]"/>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
|
it('redirectsTheOldFollowingUrlToTheCommunityTab', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/news': json(200, {results: []}),
|
||||||
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
|
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||||
|
'GET /api/library/steam-profile': json(200, null),
|
||||||
|
});
|
||||||
|
const {router} = renderWithProviders(<App/>, {entries: ['/following']});
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe('/community?tab=following'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the document language to the UI language', () => {
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {settings: {}})});
|
||||||
|
localStorage.clear();
|
||||||
|
renderWithProviders(<App/>, {entries: ['/impressum']});
|
||||||
|
|
||||||
|
expect(document.documentElement.lang).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
it('ends the session on a 401 that the refresh cannot fix', async () => {
|
it('ends the session on a 401 that the refresh cannot fix', async () => {
|
||||||
let expired = false;
|
let expired = false;
|
||||||
const library = () => (expired ? json(401, {}) : json(200, page));
|
const library = () => (expired ? json(401, {}) : json(200, page));
|
||||||
@@ -35,8 +58,8 @@ describe('App', () => {
|
|||||||
'GET /api/library/steam-profile': json(200, null),
|
'GET /api/library/steam-profile': json(200, null),
|
||||||
'POST /api/auth/refresh': json(401, {}),
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/news': json(200, {results: []}),
|
||||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
'GET /api/library/removed-games': json(200, {results: []}),
|
|
||||||
});
|
});
|
||||||
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
||||||
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function AccountInfoCard() {
|
|||||||
const {user: profile} = useCurrentUserContext();
|
const {user: profile} = useCurrentUserContext();
|
||||||
const accountRows = [
|
const accountRows = [
|
||||||
[t('SETTINGS.EMAIL'), profile?.email],
|
[t('SETTINGS.EMAIL'), profile?.email],
|
||||||
[t('SETTINGS.ROLE'), profile?.role],
|
[t('SETTINGS.ROLE'), profile?.role && t(`ROLES.${profile.role}`)],
|
||||||
[t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
|
[t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
|
||||||
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
|
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, screen} from '@testing-library/react';
|
||||||
|
import AccountInfoCard from './AccountInfoCard.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AccountInfoCard', () => {
|
||||||
|
it('shows the role by its translated name, not the enum value', async () => {
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {email: 'a@b.de', role: 'PLATFORM_ADMIN', settings: {}})});
|
||||||
|
renderWithProviders(<AccountInfoCard/>, {path: '/settings', entries: ['/settings']});
|
||||||
|
|
||||||
|
expect(await screen.findByText('ROLES.PLATFORM_ADMIN')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('PLATFORM_ADMIN')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,18 +15,21 @@ import GameSearch from './GameSearch.jsx';
|
|||||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||||
import useStartImport from '../hooks/useStartImport.js';
|
import useStartImport from '../hooks/useStartImport.js';
|
||||||
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||||
import {addGame, hoursToMinutes} from '../utils/manualGames.js';
|
import {addGame, hitTitle, hoursToMinutes} from '../utils/manualGames.js';
|
||||||
import {preselectPlatform} from '../utils/platforms.js';
|
import {preselectPlatform} from '../utils/platforms.js';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
|
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
|
||||||
* them on the import page.
|
* them on the import page.
|
||||||
|
*
|
||||||
|
* @param onAdded called with the new appid before the navigation, for a page that shows the same address
|
||||||
|
* @param initialQuery the search text to start with, e.g. the name of a game seen elsewhere
|
||||||
*/
|
*/
|
||||||
export default function AddGamePanel({onClose}) {
|
export default function AddGamePanel({onClose, onAdded, initialQuery = ''}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState(initialQuery);
|
||||||
const [picked, setPicked] = useState(null);
|
const [picked, setPicked] = useState(null);
|
||||||
// igdbId -> hit, over several searches.
|
// igdbId -> hit, over several searches.
|
||||||
const [selected, setSelected] = useState(() => new Map());
|
const [selected, setSelected] = useState(() => new Map());
|
||||||
@@ -62,7 +65,8 @@ export default function AddGamePanel({onClose}) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{picked ? (
|
{picked ? (
|
||||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}/>
|
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
|
||||||
|
onAdded={onAdded}/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
|
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
|
||||||
@@ -82,7 +86,7 @@ export default function AddGamePanel({onClose}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddForm({hit, favorite, onBack, navigate}) {
|
export function AddForm({hit, favorite, onBack, navigate, onAdded}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
|
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
|
||||||
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
|
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
|
||||||
@@ -113,6 +117,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
|
|||||||
const {appid} = await response.json();
|
const {appid} = await response.json();
|
||||||
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
|
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
|
||||||
check(appid, {status, tell: true});
|
check(appid, {status, tell: true});
|
||||||
|
onAdded?.(appid);
|
||||||
navigate(`/library/${appid}`);
|
navigate(`/library/${appid}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('add game error:', err);
|
console.error('add game error:', err);
|
||||||
@@ -127,7 +132,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
|
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
|
||||||
<div className="@container min-w-0 flex-1 font-medium">
|
<div className="@container min-w-0 flex-1 font-medium">
|
||||||
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
|
<FitText text={hitTitle(hit)}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react';
|
import {Bell, Coins, Eye, Languages, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, UserRound} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -19,27 +19,17 @@ import {
|
|||||||
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import Logo from './Logo.jsx';
|
import Logo from './Logo.jsx';
|
||||||
|
import {isNavActive, NAV_ITEMS} from './navItems.js';
|
||||||
import PageContainer from './PageContainer.jsx';
|
import PageContainer from './PageContainer.jsx';
|
||||||
import SteamAvatar from './SteamAvatar.jsx';
|
import SteamAvatar from './SteamAvatar.jsx';
|
||||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||||
|
import useNotifications from '../hooks/useNotifications.js';
|
||||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||||
import {canCorrect} from '../utils/corrections.js';
|
import {canCorrect} from '../utils/corrections.js';
|
||||||
import {fetchInbox} from '../utils/inbox.js';
|
import {profileHref} from '../utils/people.js';
|
||||||
import {fetchRemovedGames} from '../utils/manualGames.js';
|
|
||||||
import {fetchQuestionCount} from '../utils/questions.js';
|
|
||||||
import {expireSession, markSession} from '../utils/auth.js';
|
import {expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
|
||||||
// The wishlist and the backlog order are tabs of the library.
|
|
||||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
|
|
||||||
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
|
||||||
{to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark},
|
|
||||||
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
|
||||||
{to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CountBadge = ({count}) => (
|
const CountBadge = ({count}) => (
|
||||||
<span aria-hidden="true"
|
<span aria-hidden="true"
|
||||||
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||||
@@ -48,7 +38,7 @@ const CountBadge = ({count}) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const navLinkClass = (isActive) => cn(
|
const navLinkClass = (isActive) => cn(
|
||||||
'flex items-center gap-2 rounded-lg px-1 py-2 text-sm font-medium transition-colors sm:px-3',
|
'flex min-w-0 items-center gap-1.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors lg:gap-2 lg:px-3',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
@@ -82,45 +72,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
}, [seesAdminPage, pathname, menuOpenings]);
|
}, [seesAdminPage, pathname, menuOpenings]);
|
||||||
const shownCount = seesAdminPage ? pendingCount : 0;
|
const shownCount = seesAdminPage ? pendingCount : 0;
|
||||||
|
|
||||||
// Loaded again on every page change. The inbox page marks the notes read, so it shows no count.
|
// The inbox page lists all of it, so it shows no count.
|
||||||
const [unread, setUnread] = useState(0);
|
const {total} = useNotifications(isAuthenticated, pathname);
|
||||||
useEffect(() => {
|
const shownTotal = pathname === '/inbox' ? 0 : total;
|
||||||
if (!isAuthenticated) return undefined;
|
|
||||||
let current = true;
|
|
||||||
fetchInbox().then(({ok, data}) => {
|
|
||||||
if (current && ok) setUnread(data.unread);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
current = false;
|
|
||||||
};
|
|
||||||
}, [isAuthenticated, pathname]);
|
|
||||||
const shownUnread = pathname === '/inbox' ? 0 : unread;
|
|
||||||
|
|
||||||
// Loaded again on every page change. The questions page shows the live count in its title, so it has no badge.
|
|
||||||
const [openQuestions, setOpenQuestions] = useState(0);
|
|
||||||
const [hasRemovedGames, setHasRemovedGames] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated) return undefined;
|
|
||||||
let current = true;
|
|
||||||
// The removed games are restored on the questions page, so they keep its link on a phone. Asked only
|
|
||||||
// while no question is open: an open question keeps the link anyway.
|
|
||||||
fetchQuestionCount().then(({ok, data}) => {
|
|
||||||
if (!current || !ok) return;
|
|
||||||
setOpenQuestions(data.count);
|
|
||||||
if (data.count === 0 && pathname !== '/questions') {
|
|
||||||
fetchRemovedGames().then((removed) => {
|
|
||||||
if (current && removed.ok) setHasRemovedGames(removed.data.results.length > 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
current = false;
|
|
||||||
};
|
|
||||||
}, [isAuthenticated, pathname]);
|
|
||||||
const shownQuestions = pathname === '/questions' ? 0 : openQuestions;
|
|
||||||
|
|
||||||
// Below sm the Questions icon shows only with open questions, removed games or on its own page; the points chip then gives up its number.
|
|
||||||
const questionsIconHidden = openQuestions === 0 && !hasRemovedGames && pathname !== '/questions';
|
|
||||||
|
|
||||||
const switchRole = async (role) => {
|
const switchRole = async (role) => {
|
||||||
try {
|
try {
|
||||||
@@ -156,7 +110,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<PageContainer className="flex h-16 items-center gap-1 px-4 sm:gap-2 md:px-8">
|
<PageContainer className="flex h-16 items-center gap-1 px-3 sm:gap-2 sm:px-4 md:px-8">
|
||||||
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="flex items-center gap-2 font-bold tracking-tight sm:mr-2 whitespace-nowrap">
|
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="flex items-center gap-2 font-bold tracking-tight sm:mr-2 whitespace-nowrap">
|
||||||
<Logo className="h-8 w-auto shrink-0"/>
|
<Logo className="h-8 w-auto shrink-0"/>
|
||||||
{/* On a narrow phone the menu and buttons need the room; the logo alone stays. */}
|
{/* On a narrow phone the menu and buttons need the room; the logo alone stays. */}
|
||||||
@@ -164,18 +118,15 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<nav className="flex items-center sm:gap-1">
|
<nav aria-label={t('NAV.MENU')} className="hidden min-w-0 items-center gap-0.5 md:flex lg:gap-1">
|
||||||
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = [], iconOnly}) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const count = to === '/questions' ? shownQuestions : 0;
|
const active = isNavActive(item, pathname);
|
||||||
return (
|
return (
|
||||||
<NavLink key={to} to={to} title={t(labelKey)}
|
<Link key={item.to} to={item.to} title={t(item.labelKey)} aria-current={active ? 'page' : undefined}
|
||||||
aria-label={count > 0 ? t(`${labelKey}_OPEN`, {count}) : undefined}
|
className={navLinkClass(active)}>
|
||||||
className={({isActive}) => cn(navLinkClass(isActive || alsoActiveOn.includes(pathname)), 'relative',
|
<item.Icon className="size-4 shrink-0"/>
|
||||||
to === '/questions' && questionsIconHidden && 'max-sm:hidden')}>
|
<span className="sr-only lg:not-sr-only lg:truncate">{t(item.labelKey)}</span>
|
||||||
<Icon className="size-4"/>
|
</Link>
|
||||||
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
|
|
||||||
{count > 0 && <CountBadge count={count}/>}
|
|
||||||
</NavLink>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -187,18 +138,18 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
<Badge variant="secondary" className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none border-border">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
|
<Badge variant="secondary" className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none border-border">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
|
||||||
)}
|
)}
|
||||||
{isAuthenticated && wallet && (
|
{isAuthenticated && wallet && (
|
||||||
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink lg:shrink-0">
|
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="shrink-0">
|
||||||
<Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
|
<Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
|
||||||
<Coins className="text-primary"/>
|
<Coins className="text-primary"/>
|
||||||
<span className={cn('min-w-0 truncate tabular-nums', !questionsIconHidden && 'max-sm:hidden')}>{wallet.balance}</span>
|
<span className="min-w-0 truncate tabular-nums">{wallet.balance}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
|
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
|
||||||
<Link to="/inbox" aria-label={shownUnread > 0 ? t('NAV.INBOX_UNREAD', {count: shownUnread}) : t('NAV.INBOX')}>
|
<Link to="/inbox" aria-label={shownTotal > 0 ? t('NAV.INBOX_UNREAD', {count: shownTotal}) : t('NAV.INBOX')}>
|
||||||
<Bell/>
|
<Bell/>
|
||||||
{shownUnread > 0 && <CountBadge count={shownUnread}/>}
|
{shownTotal > 0 && <CountBadge count={shownTotal}/>}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -210,22 +161,24 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
<SteamAvatar url={steamProfile?.avatarUrl}/>
|
<SteamAvatar url={steamProfile?.avatarUrl}/>
|
||||||
{shownCount > 0 && (
|
{shownCount > 0 && (
|
||||||
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})}
|
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})}
|
||||||
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-amber-500 text-[10px] font-bold text-black ring-2 ring-background">
|
||||||
{shownCount > 9 ? '9+' : shownCount}
|
{shownCount > 9 ? '9+' : shownCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-52">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
{currentUser?.displayName && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={profileHref(currentUser.displayName)}><UserRound/>{t('NAV.MY_PROFILE')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
|
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/inbox"><Bell/>{t('NAV.INBOX')}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
|
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -247,7 +200,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/admin/users">
|
<Link to="/admin/users">
|
||||||
<Shield/>{t('NAV.ADMIN')}
|
<Shield/>{t('NAV.ADMIN')}
|
||||||
{shownCount > 0 && <Badge className="ml-auto">{shownCount}</Badge>}
|
{shownCount > 0 && <Badge variant="outline" className="ml-auto border-amber-500 text-amber-600 dark:text-amber-400">{shownCount}</Badge>}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ function Load() {
|
|||||||
return <button onClick={reloadWallet}>load</button>;
|
return <button onClick={reloadWallet}>load</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChip(count) {
|
async function loadChip() {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
||||||
'GET /api/library/steam-profile': json(200, {}),
|
'GET /api/library/steam-profile': json(200, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
'GET /api/library/questions/count': json(200, {count}),
|
'GET /api/news': json(200, {results: []}),
|
||||||
'GET /api/library/removed-games': json(200, {results: []}),
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
});
|
});
|
||||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||||
@@ -34,20 +34,10 @@ async function loadChip(count) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AppHeader points chip', () => {
|
describe('AppHeader points chip', () => {
|
||||||
it('hides the number below sm while the questions icon shows, and keeps the balance as label', async () => {
|
it('showsTheChipAtEveryWidth', async () => {
|
||||||
const chip = await loadChip(2);
|
const chip = await loadChip();
|
||||||
await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'});
|
|
||||||
|
|
||||||
expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135');
|
expect(chip.className).not.toContain('max-sm:hidden');
|
||||||
expect(screen.getByText('135').className).toContain('max-sm:hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the number at every width while the questions icon is hidden', async () => {
|
|
||||||
const chip = await loadChip(0);
|
|
||||||
await screen.findByTitle('NAV.QUESTIONS');
|
|
||||||
|
|
||||||
expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135');
|
|
||||||
expect(screen.getByText('135').className).not.toContain('max-sm:hidden');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the balance as a link to the points page once the wallet is loaded', async () => {
|
it('shows the balance as a link to the points page once the wallet is loaded', async () => {
|
||||||
@@ -56,8 +46,8 @@ describe('AppHeader points chip', () => {
|
|||||||
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
||||||
'GET /api/library/steam-profile': json(200, {}),
|
'GET /api/library/steam-profile': json(200, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
'GET /api/news': json(200, {results: []}),
|
||||||
'GET /api/library/removed-games': json(200, {results: []}),
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
});
|
});
|
||||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, screen} from '@testing-library/react';
|
import {cleanup, fireEvent, screen, within} from '@testing-library/react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import AppHeader from './AppHeader.jsx';
|
import AppHeader from './AppHeader.jsx';
|
||||||
@@ -20,8 +20,9 @@ const loggedInAs = (me, routes = {}, options = {}) => {
|
|||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
|
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/news': json(200, {results: []}),
|
||||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
'GET /api/library/removed-games': json(200, {results: []}), ...routes});
|
...routes});
|
||||||
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
|
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,29 +47,59 @@ describe('AppHeader', () => {
|
|||||||
screen.getByText('ToBePlayed').style.display = 'none';
|
screen.getByText('ToBePlayed').style.display = 'none';
|
||||||
|
|
||||||
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
|
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
|
||||||
expect(screen.queryByTitle('NAV.COMMUNITY')).toBeNull();
|
expect(screen.queryByRole('navigation', {name: 'NAV.MENU'})).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('links to the community stats when logged in', () => {
|
it('showsTheFiveMainItemsWithTheirLabelsAtEveryWidth', () => {
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||||
|
|
||||||
const link = screen.getByTitle('NAV.COMMUNITY');
|
const nav = screen.getByRole('navigation', {name: 'NAV.MENU'});
|
||||||
|
|
||||||
expect(link.getAttribute('href')).toBe('/');
|
expect(within(nav).getAllByRole('link').map((link) => link.getAttribute('href')))
|
||||||
expect(link.getAttribute('aria-current')).toBe('page');
|
.toEqual(['/dashboard', '/library', '/play-next', '/stats', '/community']);
|
||||||
|
for (const key of ['DASHBOARD', 'LIBRARY', 'PLAY_NEXT', 'STATS', 'COMMUNITY']) {
|
||||||
|
expect(within(nav).getByText(`NAV.${key}`).className).not.toContain('hidden');
|
||||||
|
}
|
||||||
|
expect(nav.className).toContain('md:flex');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the community link as an icon without a text label', () => {
|
it('hasNoQuestionsFollowingOrSearchItemInTheNav', () => {
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||||
|
|
||||||
expect(screen.getByTitle('NAV.COMMUNITY').textContent).toBe('');
|
const nav = screen.getByRole('navigation', {name: 'NAV.MENU'});
|
||||||
|
|
||||||
|
for (const key of ['QUESTIONS', 'FOLLOWING', 'SEARCH']) expect(within(nav).queryByText(`NAV.${key}`)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks community active only on the landing page', () => {
|
it('marksTheLibraryActiveOnItsTabsAndSearch', () => {
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {}, {entries: ['/dashboard']});
|
loggedInAs({role: 'USER', activeRole: 'USER'}, {}, {entries: ['/search']});
|
||||||
|
|
||||||
expect(screen.getByTitle('NAV.COMMUNITY').getAttribute('aria-current')).toBeNull();
|
const nav = screen.getByRole('navigation', {name: 'NAV.MENU'});
|
||||||
expect(screen.getByTitle('NAV.DASHBOARD').getAttribute('aria-current')).toBe('page');
|
|
||||||
|
expect(within(nav).getByRole('link', {name: 'NAV.LIBRARY'}).getAttribute('aria-current')).toBe('page');
|
||||||
|
expect(within(nav).getByRole('link', {name: 'NAV.DASHBOARD'}).getAttribute('aria-current')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offersMyPublicProfileFirstInTheMenuWhenTheUserHasAName', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER', displayName: 'Lo bo'});
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||||
|
await screen.findByRole('button', {name: 'NAV.MENU'});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
openMenu();
|
||||||
|
expect(screen.getAllByRole('menuitem')[0].getAttribute('href')).toBe('/u/Lo%20bo');
|
||||||
|
});
|
||||||
|
expect(screen.getAllByRole('menuitem')[0].textContent).toBe('NAV.MY_PROFILE');
|
||||||
|
expect(screen.queryByRole('menuitem', {name: 'NAV.INBOX'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offersNoProfileItemWithoutAName', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||||
|
openMenu();
|
||||||
|
|
||||||
|
await screen.findByText('NAV.SETTINGS');
|
||||||
|
expect(screen.queryByText('NAV.MY_PROFILE')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows how many inbox notes are unread', async () => {
|
it('shows how many inbox notes are unread', async () => {
|
||||||
@@ -80,6 +111,24 @@ describe('AppHeader', () => {
|
|||||||
expect(link.textContent).toBe('3');
|
expect(link.textContent).toBe('3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('theBellCountsUnreadNotesUnseenNewsAndOpenQuestions', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER'}, {
|
||||||
|
'GET /api/inbox': json(200, {results: [], unread: 2}),
|
||||||
|
'GET /api/news': json(200, {results: [{id: 1, seen: false}, {id: 2, seen: true}]}),
|
||||||
|
'GET /api/library/questions/count': json(200, {count: 4}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await screen.findByRole('link', {name: 'NAV.INBOX_UNREAD:7'})).textContent).toBe('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('theInboxPageShowsNoBellCount', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 4})},
|
||||||
|
{entries: ['/inbox']});
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/library/questions/count'), expect.anything()));
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', {name: 'NAV.INBOX'}).textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows no count without unread notes', async () => {
|
it('shows no count without unread notes', async () => {
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||||
|
|
||||||
@@ -88,62 +137,6 @@ describe('AppHeader', () => {
|
|||||||
expect(link.textContent).toBe('');
|
expect(link.textContent).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows how many questions are open', async () => {
|
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 12})});
|
|
||||||
|
|
||||||
const link = await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:12'});
|
|
||||||
|
|
||||||
expect(link.getAttribute('href')).toBe('/questions');
|
|
||||||
expect(link.textContent).toBe('NAV.QUESTIONS9+');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the questions icon below sm while no question is open', async () => {
|
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
|
||||||
|
|
||||||
expect((await screen.findByTitle('NAV.QUESTIONS')).className).toContain('max-sm:hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the questions icon at every width while questions are open', async () => {
|
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2})});
|
|
||||||
|
|
||||||
expect((await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'})).className).not.toContain('max-sm:hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the questions icon at every width while the user has removed games', async () => {
|
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'},
|
|
||||||
{'GET /api/library/removed-games': json(200, {results: [{appid: 1, name: 'Game'}]})});
|
|
||||||
|
|
||||||
await vi.waitFor(() => expect(screen.getByTitle('NAV.QUESTIONS').className).not.toContain('max-sm:hidden'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skipsTheRemovedGamesCallWhileQuestionsAreOpen', async () => {
|
|
||||||
const removed = vi.fn(() => json(200, {results: []}));
|
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2}),
|
|
||||||
'GET /api/library/removed-games': removed});
|
|
||||||
|
|
||||||
await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'});
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
expect(removed).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the questions icon below sm on the questions page, without a badge', async () => {
|
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 0})},
|
|
||||||
{entries: ['/questions']});
|
|
||||||
|
|
||||||
expect((await screen.findByTitle('NAV.QUESTIONS')).className).not.toContain('max-sm:hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows no question count on the questions page, whose title has it', async () => {
|
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 3})},
|
|
||||||
{entries: ['/questions']});
|
|
||||||
|
|
||||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/library/questions/count'), expect.anything()));
|
|
||||||
|
|
||||||
expect(screen.getByTitle('NAV.QUESTIONS').textContent).toBe('NAV.QUESTIONS');
|
|
||||||
expect(screen.queryByRole('link', {name: 'NAV.QUESTIONS_OPEN:3'})).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offers the admin page and "view as" to an admin', async () => {
|
it('offers the admin page and "view as" to an admin', async () => {
|
||||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||||
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
|
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
|
||||||
@@ -164,7 +157,8 @@ describe('AppHeader', () => {
|
|||||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||||
{'GET /api/admin/users/pending-count': json(200, {count: 3})});
|
{'GET /api/admin/users/pending-count': json(200, {count: 3})});
|
||||||
|
|
||||||
expect(await screen.findByLabelText('NAV.PENDING_REQUESTS:3')).toBeTruthy();
|
const badge = await screen.findByLabelText('NAV.PENDING_REQUESTS:3');
|
||||||
|
expect(badge.className).toContain('bg-amber-500');
|
||||||
openMenu();
|
openMenu();
|
||||||
expect((await screen.findByRole('menuitem', {name: /^NAV\.ADMIN ?3$/})).textContent).toContain('3');
|
expect((await screen.findByRole('menuitem', {name: /^NAV\.ADMIN ?3$/})).textContent).toContain('3');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,17 +9,26 @@ import {Switch} from '@/components/ui/switch';
|
|||||||
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
|
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
|
||||||
import {equippedSkin} from '../utils/rewards.js';
|
import {equippedSkin} from '../utils/rewards.js';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
|
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||||
import {coverUrl} from '../utils/games.js';
|
import {coverUrl} from '../utils/games.js';
|
||||||
import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js';
|
import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js';
|
||||||
|
import {saveSettings} from '../utils/settings.js';
|
||||||
|
|
||||||
export default function AppearanceCard() {
|
export default function AppearanceCard() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {appearance, equipped} = usePreferences();
|
const {appearance, equipped, isDarkMode, setDarkMode} = usePreferences();
|
||||||
|
const persistSettings = usePersistSettings();
|
||||||
const saveAppearance = useSaveAppearance();
|
const saveAppearance = useSaveAppearance();
|
||||||
const [tilt, setTilt] = useState(tiltAllowed);
|
const [tilt, setTilt] = useState(tiltAllowed);
|
||||||
const [tiltRefused, setTiltRefused] = useState(false);
|
const [tiltRefused, setTiltRefused] = useState(false);
|
||||||
|
|
||||||
|
const changeDarkMode = async (next) => {
|
||||||
|
setDarkMode(next);
|
||||||
|
const {ok} = await persistSettings(() => saveSettings({useDarkTheme: next}), {silent: true});
|
||||||
|
if (!ok) setDarkMode(!next);
|
||||||
|
};
|
||||||
|
|
||||||
// requestTilt() starts inside the tap, as iOS demands.
|
// requestTilt() starts inside the tap, as iOS demands.
|
||||||
const changeTilt = (on) => {
|
const changeTilt = (on) => {
|
||||||
if (!on) {
|
if (!on) {
|
||||||
@@ -39,6 +48,10 @@ export default function AppearanceCard() {
|
|||||||
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
|
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Label htmlFor="useDarkTheme">{t('SETTINGS.DARK_THEME')}</Label>
|
||||||
|
<Switch id="useDarkTheme" checked={isDarkMode} onCheckedChange={changeDarkMode}/>
|
||||||
|
</div>
|
||||||
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
|
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
|
||||||
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
|
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
|
||||||
{equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>}
|
{equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
|
||||||
import {ListTodo, Loader2} from 'lucide-react';
|
import {ListTodo, Loader2} from 'lucide-react';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
@@ -8,58 +7,25 @@ import {Card} from '@/components/ui/card';
|
|||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||||
import {Textarea} from '@/components/ui/textarea';
|
import {Textarea} from '@/components/ui/textarea';
|
||||||
import RatingStars from './RatingStars.jsx';
|
import {changedFields, NO_PRIORITY} from '../utils/backlog.js';
|
||||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
import {GAME_PRIORITIES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
||||||
import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js';
|
import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
|
||||||
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
|
||||||
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
||||||
|
|
||||||
const toForm = (entry) => ({
|
/**
|
||||||
status: entry.status ?? 'BACKLOG',
|
* Priority (saved on change) and notes (saved with the Save button). Status and rating live in the hero card.
|
||||||
rating: entry.rating ?? 0,
|
*
|
||||||
notes: entry.notes ?? '',
|
* @param onSaved gets the saved backlog entry
|
||||||
priority: entry.priority ?? NO_PRIORITY,
|
*/
|
||||||
});
|
|
||||||
|
|
||||||
export default function BacklogCard({game, onSaved}) {
|
export default function BacklogCard({game, onSaved}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [saved, setSaved] = useState(() => toForm(game));
|
const [notes, setNotes] = useState(game.notes ?? '');
|
||||||
const [form, setForm] = useState(saved);
|
const {save, pending, busy} = useSaveBacklogField(game.appid, {onSaved});
|
||||||
|
|
||||||
// The PlayingHint can change the status from outside; keep the other unsaved edits.
|
const savingNotes = pending?.field === 'notes';
|
||||||
useEffect(() => {
|
const dirty = 'notes' in changedFields({notes: game.notes}, {notes});
|
||||||
setSaved((current) => (current.status === game.status ? current : {...current, status: game.status}));
|
|
||||||
setForm((current) => (current.status === game.status ? current : {...current, status: game.status}));
|
|
||||||
}, [game.status]);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const changes = changedFields(saved, form);
|
|
||||||
const dirty = Object.keys(changes).length > 0;
|
|
||||||
useUnsavedChanges(dirty);
|
useUnsavedChanges(dirty);
|
||||||
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
const priority = pending?.field === 'priority' ? pending.value : (game.priority ?? NO_PRIORITY);
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await saveBacklogEntry(game.appid, changes);
|
|
||||||
if (response.status === 401) return;
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const entry = await response.json();
|
|
||||||
const next = toForm(entry);
|
|
||||||
setSaved(next);
|
|
||||||
onSaved?.(entry);
|
|
||||||
setForm(next);
|
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('backlog save error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-6 gap-5">
|
<Card className="p-6 gap-5">
|
||||||
@@ -68,68 +34,42 @@ export default function BacklogCard({game, onSaved}) {
|
|||||||
{t('BACKLOG.TITLE')}
|
{t('BACKLOG.TITLE')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Locked while saving: the answer replaces the form, so an edit made meanwhile would be lost. */}
|
<div className="space-y-2 sm:max-w-xs">
|
||||||
<fieldset disabled={busy} className="contents">
|
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
|
||||||
<div className="grid gap-5 sm:grid-cols-3">
|
<Select value={priority} onValueChange={(value) => save('priority', value)} disabled={busy}>
|
||||||
<div className="space-y-2">
|
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
|
||||||
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
|
<SelectContent>
|
||||||
<Select value={form.status} onValueChange={set('status')}>
|
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
|
||||||
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
|
{GAME_PRIORITIES.map((p) => (
|
||||||
<SelectContent>
|
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
|
||||||
{GAME_STATUSES.map((s) => {
|
))}
|
||||||
const {icon: Icon, text} = STATUS_META[s];
|
</SelectContent>
|
||||||
return (
|
</Select>
|
||||||
<SelectItem key={s} value={s}>
|
</div>
|
||||||
<Icon className={text}/>
|
|
||||||
{t(`BACKLOG.STATUS.${s}`)}
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
|
<div className="flex items-baseline justify-between">
|
||||||
<Select value={form.priority} onValueChange={set('priority')}>
|
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
|
||||||
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
|
<span className="text-xs text-muted-foreground tabular-nums">{notes.length} / {MAX_NOTES_LENGTH}</span>
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
|
|
||||||
{GAME_PRIORITIES.map((p) => (
|
|
||||||
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
|
|
||||||
<RatingStars value={form.rating} onChange={set('rating')} labelledBy="backlog-rating"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Locked while saving: an edit made meanwhile would be lost. */}
|
||||||
<div className="space-y-2">
|
<Textarea
|
||||||
<div className="flex items-baseline justify-between">
|
id="backlog-notes"
|
||||||
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
|
rows={3}
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {MAX_NOTES_LENGTH}</span>
|
maxLength={MAX_NOTES_LENGTH}
|
||||||
</div>
|
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
|
||||||
<Textarea
|
value={notes}
|
||||||
id="backlog-notes"
|
disabled={savingNotes}
|
||||||
rows={3}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
maxLength={MAX_NOTES_LENGTH}
|
/>
|
||||||
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
|
</div>
|
||||||
value={form.notes}
|
|
||||||
onChange={(e) => set('notes')(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
|
<Button variant="ghost" onClick={() => setNotes(game.notes ?? '')} disabled={!dirty || busy}>
|
||||||
{t('BACKLOG.RESET')}
|
{t('BACKLOG.RESET')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={save} disabled={!dirty || busy}>
|
<Button onClick={() => save('notes', notes)} disabled={!dirty || busy}>
|
||||||
{busy && <Loader2 className="animate-spin"/>}
|
{savingNotes && <Loader2 className="animate-spin"/>}
|
||||||
{t('BACKLOG.SAVE')}
|
{t('BACKLOG.SAVE')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import {toast} from 'sonner';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
import BacklogCard from './BacklogCard.jsx';
|
import BacklogCard from './BacklogCard.jsx';
|
||||||
|
|
||||||
@@ -10,45 +11,88 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
|||||||
|
|
||||||
const game = {appid: 220, status: 'PLAYING', rating: 4, notes: 'old', priority: null};
|
const game = {appid: 220, status: 'PLAYING', rating: 4, notes: 'old', priority: null};
|
||||||
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
|
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
|
||||||
|
const PATCH = 'PATCH /api/library/games/220/backlog';
|
||||||
|
const openPriority = () => fireEvent.keyDown(screen.getByRole('combobox'), {key: 'Enter'});
|
||||||
|
const patchBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([, init]) => JSON.parse(init.body));
|
||||||
|
|
||||||
describe('BacklogCard', () => {
|
describe('BacklogCard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// jsdom lacks both; the Radix select needs them when it opens.
|
||||||
|
Element.prototype.hasPointerCapture ??= () => false;
|
||||||
|
Element.prototype.scrollIntoView ??= () => {};
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
// Core answers the whole entry; the fields not sent come from the server.
|
// Core answers the whole entry; the fields not sent come from the server.
|
||||||
fetchRoutes({'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
|
fetchRoutes({[PATCH]: (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables saving only after a change', () => {
|
it('showsNeitherStatusNorRating', () => {
|
||||||
|
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.queryByText('BACKLOG.STATUS_LABEL')).toBeNull();
|
||||||
|
expect(screen.queryByText('BACKLOG.RATING_LABEL')).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', {name: /BACKLOG.RATING_STARS/})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('savesAPriorityAtOnceAndOnlyThatField', async () => {
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
|
||||||
|
|
||||||
|
openPriority();
|
||||||
|
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.HIGH'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({...game, priority: 'HIGH'}));
|
||||||
|
expect(patchBodies()).toEqual([{priority: 'HIGH'}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearsThePriorityWithNull', async () => {
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
render(<MemoryRouter><BacklogCard game={{...game, priority: 'LOW'}} onSaved={onSaved}/></MemoryRouter>);
|
||||||
|
|
||||||
|
openPriority();
|
||||||
|
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.NONE'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||||
|
expect(patchBodies()).toEqual([{priority: null}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheSavedPriorityAgainWhenSavingFails', async () => {
|
||||||
|
fetchRoutes({[PATCH]: json(500, {})});
|
||||||
|
render(<MemoryRouter><BacklogCard game={{...game, priority: 'LOW'}}/></MemoryRouter>);
|
||||||
|
|
||||||
|
openPriority();
|
||||||
|
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.HIGH'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
|
||||||
|
expect(screen.getByRole('combobox').textContent).toContain('BACKLOG.PRIORITY.LOW');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enablesSavingOnlyAfterANotesChange', () => {
|
||||||
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
|
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
|
||||||
expect(saveButton().disabled).toBe(true);
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
|
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
|
||||||
|
|
||||||
expect(saveButton().disabled).toBe(false);
|
expect(saveButton().disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the rating on a click on the current star and sends blank notes as null', async () => {
|
it('sendsBlankNotesAsNull', async () => {
|
||||||
const onSaved = vi.fn();
|
const onSaved = vi.fn();
|
||||||
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
|
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
|
|
||||||
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
|
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
|
||||||
fireEvent.click(saveButton());
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||||
const [url, init] = fetch.mock.calls[0];
|
expect(patchBodies()).toEqual([{notes: null}]);
|
||||||
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
|
|
||||||
expect(init.method).toBe('PATCH');
|
|
||||||
expect(JSON.parse(init.body)).toEqual({rating: null, notes: null});
|
|
||||||
expect(saveButton().disabled).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends only the changed notes, so an older tab does not overwrite the other fields', async () => {
|
it('sendsOnlyTheChangedNotes', async () => {
|
||||||
const onSaved = vi.fn();
|
const onSaved = vi.fn();
|
||||||
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
|
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
|
||||||
|
|
||||||
@@ -56,31 +100,39 @@ describe('BacklogCard', () => {
|
|||||||
fireEvent.click(saveButton());
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({notes: 'new notes'});
|
expect(patchBodies()).toEqual([{notes: 'new notes'}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('locks the fields while the save runs, so nothing typed then is lost', async () => {
|
it('locksTheNotesWhileTheSaveRuns', async () => {
|
||||||
let answer;
|
let answer;
|
||||||
fetchRoutes({'PATCH /api/library/games/220/backlog': (url, init) => new Promise((resolve) => {
|
fetchRoutes({[PATCH]: (url, init) => new Promise((resolve) => {
|
||||||
answer = () => resolve(json(200, {...game, ...JSON.parse(init.body)}));
|
answer = () => resolve(json(200, {...game, ...JSON.parse(init.body)}));
|
||||||
})});
|
})});
|
||||||
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
|
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
|
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
|
||||||
fireEvent.click(saveButton());
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
// :disabled, not .disabled: the fieldset locks them, not their own attribute.
|
|
||||||
expect(screen.getByRole('textbox').matches(':disabled')).toBe(true);
|
expect(screen.getByRole('textbox').matches(':disabled')).toBe(true);
|
||||||
expect(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:1'}).matches(':disabled')).toBe(true);
|
|
||||||
await act(async () => answer());
|
await act(async () => answer());
|
||||||
expect(screen.getByRole('textbox').matches(':disabled')).toBe(false);
|
expect(screen.getByRole('textbox').matches(':disabled')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not count blank notes instead of none as a change', () => {
|
it('doesNotCountBlankNotesInsteadOfNoneAsAChange', () => {
|
||||||
render(<MemoryRouter><BacklogCard game={{...game, notes: null}}/></MemoryRouter>);
|
render(<MemoryRouter><BacklogCard game={{...game, notes: null}}/></MemoryRouter>);
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
|
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
|
||||||
|
|
||||||
expect(saveButton().disabled).toBe(true);
|
expect(saveButton().disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resetBringsBackTheSavedNotes', () => {
|
||||||
|
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'draft'}});
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RESET'}));
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox').value).toBe('old');
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {Link, useLocation} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
import {isNavActive, NAV_ITEMS} from './navItems.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main navigation on a phone: a bar at the bottom edge, every item with its label.
|
||||||
|
*/
|
||||||
|
export default function BottomNav() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {pathname} = useLocation();
|
||||||
|
return (
|
||||||
|
<nav aria-label={t('NAV.MENU')}
|
||||||
|
className="fixed inset-x-0 bottom-0 z-50 flex border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = isNavActive(item, pathname);
|
||||||
|
return (
|
||||||
|
<Link key={item.to} to={item.to} aria-current={active ? 'page' : undefined}
|
||||||
|
className={cn('flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium',
|
||||||
|
active ? 'text-primary' : 'text-muted-foreground')}>
|
||||||
|
<item.Icon className="size-5"/>
|
||||||
|
<span className="max-w-full truncate px-1">{t(item.labelKey)}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, screen} from '@testing-library/react';
|
||||||
|
import BottomNav from './BottomNav.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const renderNav = (path) => {
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {settings: {}})});
|
||||||
|
renderWithProviders(<BottomNav/>, {entries: [path]});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BottomNav', () => {
|
||||||
|
it('hasOneLabeledLinkPerMainItemAndIsHiddenFromMd', () => {
|
||||||
|
renderNav('/dashboard');
|
||||||
|
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
|
||||||
|
expect(links.map((link) => link.getAttribute('href')))
|
||||||
|
.toEqual(['/dashboard', '/library', '/play-next', '/stats', '/community']);
|
||||||
|
expect(links.map((link) => link.textContent))
|
||||||
|
.toEqual(['NAV.DASHBOARD', 'NAV.LIBRARY', 'NAV.PLAY_NEXT', 'NAV.STATS', 'NAV.COMMUNITY']);
|
||||||
|
expect(screen.getByRole('navigation').className).toContain('md:hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('theActiveLinkHasAriaCurrent', () => {
|
||||||
|
renderNav('/wishlist');
|
||||||
|
|
||||||
|
const current = screen.getAllByRole('link').filter((link) => link.getAttribute('aria-current') === 'page');
|
||||||
|
|
||||||
|
expect(current.map((link) => link.getAttribute('href'))).toEqual(['/library']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Plus} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
|
import AddGamePanel from './AddGamePanel.jsx';
|
||||||
|
import FitText from './FitText.jsx';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import TimeToBeat from './TimeToBeat.jsx';
|
||||||
|
import {fetchPreview} from '../utils/catalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A game the user does not have: its catalog data and a way to add it. The add flow is the search of the library,
|
||||||
|
* started with the game's name; adding ends on the user's own game page.
|
||||||
|
*
|
||||||
|
* @param onAdded called after the game was added, so the page can load the user's own game
|
||||||
|
* @param fallback shown when the catalog does not know the game or the request fails
|
||||||
|
*/
|
||||||
|
export default function CatalogGameCard({appid, fallback, onAdded}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [preview, setPreview] = useState(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setPreview(null);
|
||||||
|
setFailed(false);
|
||||||
|
setAdding(false);
|
||||||
|
(async () => {
|
||||||
|
const {ok, data} = await fetchPreview(appid);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (ok && data) setPreview(data);
|
||||||
|
else setFailed(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [appid]);
|
||||||
|
|
||||||
|
if (failed) return fallback;
|
||||||
|
if (!preview) return <Skeleton className="h-52 rounded-xl"/>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="gap-4 p-5 md:flex-row md:items-start">
|
||||||
|
<GameCover appid={preview.appid} imageUrl={preview.imageUrl} className="w-full md:w-72 md:shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<h1 className="@container text-2xl font-bold"><FitText text={preview.name}/></h1>
|
||||||
|
{preview.genres?.length > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{preview.genres.join(', ')}</p>
|
||||||
|
)}
|
||||||
|
<TimeToBeat time={preview.timeToBeat}/>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('CATALOG.NOT_IN_LIBRARY')}</p>
|
||||||
|
{!adding && <Button onClick={() => setAdding(true)}><Plus/>{t('CATALOG.ADD')}</Button>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{adding && <AddGamePanel initialQuery={preview.name} onAdded={onAdded} onClose={() => setAdding(false)}/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {Button} from '@/components/ui/button';
|
|||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import FormField from './FormField.jsx';
|
import FormField from './FormField.jsx';
|
||||||
|
import PasswordInput from './PasswordInput.jsx';
|
||||||
import {changePassword} from '../utils/account.js';
|
import {changePassword} from '../utils/account.js';
|
||||||
import {changePasswordSchema} from '../utils/validation.js';
|
import {changePasswordSchema} from '../utils/validation.js';
|
||||||
|
|
||||||
@@ -54,13 +55,14 @@ export default function ChangePasswordCard() {
|
|||||||
<Input id="currentPassword" type="password" autoComplete="current-password"
|
<Input id="currentPassword" type="password" autoComplete="current-password"
|
||||||
aria-invalid={!!errors.currentPassword} {...register('currentPassword')}/>
|
aria-invalid={!!errors.currentPassword} {...register('currentPassword')}/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="newPassword" label={t('SETTINGS.CHANGE_PASSWORD.NEW')} error={errors.password} t={t}>
|
<FormField id="newPassword" label={t('SETTINGS.CHANGE_PASSWORD.NEW')} error={errors.password}
|
||||||
<Input id="newPassword" type="password" autoComplete="new-password"
|
description={t('VALIDATION.PASSWORD_RULES')} t={t}>
|
||||||
aria-invalid={!!errors.password} {...register('password')}/>
|
<PasswordInput id="newPassword" autoComplete="new-password" aria-invalid={!!errors.password}
|
||||||
|
aria-describedby="newPassword-description" {...register('password')}/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="newPasswordRepeat" label={t('SETTINGS.CHANGE_PASSWORD.REPEAT')} error={errors.passwordConfirmation} t={t}>
|
<FormField id="newPasswordRepeat" label={t('SETTINGS.CHANGE_PASSWORD.REPEAT')} error={errors.passwordConfirmation} t={t}>
|
||||||
<Input id="newPasswordRepeat" type="password" autoComplete="new-password"
|
<PasswordInput id="newPasswordRepeat" autoComplete="new-password"
|
||||||
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
|
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting && <Loader2 className="animate-spin"/>}
|
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ describe('ChangePasswordCard', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('showsToggleAndRulesOnTheNewPasswordFields', () => {
|
||||||
|
render(<ChangePasswordCard/>);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}).length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.NEW').getAttribute('aria-describedby'))
|
||||||
|
.toBe(screen.getByText('VALIDATION.PASSWORD_RULES').id);
|
||||||
|
});
|
||||||
|
|
||||||
it('sends the current and the new password and clears the form on success', async () => {
|
it('sends the current and the new password and clears the form on success', async () => {
|
||||||
const mock = answers(json(204));
|
const mock = answers(json(204));
|
||||||
render(<ChangePasswordCard/>);
|
render(<ChangePasswordCard/>);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Archive, Blocks, Clock, Gamepad2, Library, Link2, Sparkles, Trophy, Users} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import FitText from './FitText.jsx';
|
||||||
|
import StatCard from './StatCard.jsx';
|
||||||
|
import PeopleDialog from './people/PeopleDialog.jsx';
|
||||||
|
import {YearBars} from './stats/FinishedStats.jsx';
|
||||||
|
import {TagBars} from './stats/TasteStats.jsx';
|
||||||
|
import {StatDrillProvider, useStatDrill, useTagDrill} from './stats/StatDrill.jsx';
|
||||||
|
import useSession from '../hooks/useSession.js';
|
||||||
|
import usePublicStats from '../hooks/usePublicStats.js';
|
||||||
|
import {fetchUsers} from '../utils/people.js';
|
||||||
|
import {genreKey} from '../utils/stats.js';
|
||||||
|
|
||||||
|
function RankedGames({title, games, formatValue, drillKey}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const drill = useStatDrill();
|
||||||
|
return (
|
||||||
|
<Card className="gap-0 py-0">
|
||||||
|
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
|
||||||
|
<Trophy className="size-4 text-amber-500"/>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<ol className="divide-y">
|
||||||
|
{games.map((g, idx) => (
|
||||||
|
<li key={g.appid} className="flex items-center gap-3 px-5 py-3">
|
||||||
|
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
|
||||||
|
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
|
||||||
|
<GameName game={g}/>
|
||||||
|
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{drill && (
|
||||||
|
<Button variant="ghost" className="m-2 self-start max-sm:min-h-11"
|
||||||
|
onClick={() => drill.open(title, {key: drillKey})}>
|
||||||
|
{t('STATS.DRILL.SHOW_ALL')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameName({game}) {
|
||||||
|
return (
|
||||||
|
<span className="@container min-w-0 flex-1 font-medium">
|
||||||
|
<FitText text={game.name ?? `App ${game.appid}`}/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommunityBars({core}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const drill = useStatDrill();
|
||||||
|
const select = useTagDrill();
|
||||||
|
const genreLabel = (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name});
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
{core?.gamesPerPlatform?.length > 0 && (
|
||||||
|
<BarsCard title={t('LANDING.PLATFORMS')}>
|
||||||
|
<TagBars tags={core.gamesPerPlatform} onSelect={select('PLATFORM')}/>
|
||||||
|
</BarsCard>
|
||||||
|
)}
|
||||||
|
{core?.topGenres?.length > 0 && (
|
||||||
|
<BarsCard title={t('LANDING.TOP_GENRES')}>
|
||||||
|
<TagBars tags={core.topGenres} label={genreLabel} onSelect={select('GENRE')}/>
|
||||||
|
</BarsCard>
|
||||||
|
)}
|
||||||
|
{core?.finishedPerYear?.length > 0 && (
|
||||||
|
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
|
||||||
|
<YearBars perYear={core.finishedPerYear}
|
||||||
|
onSelect={drill && ((year) => drill.open(`${t('LANDING.FINISHED_PER_YEAR')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
|
||||||
|
</BarsCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarsCard({title, children}) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-0 py-0">
|
||||||
|
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">{title}</h3>
|
||||||
|
<div className="px-5 py-4">{children}</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The numbers of all users: totals, top games and bars. Logged-in users open the people behind a total.
|
||||||
|
*/
|
||||||
|
export default function CommunityStats() {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const {data, isLoading} = usePublicStats();
|
||||||
|
const {active} = useSession();
|
||||||
|
const [people, setPeople] = useState(null);
|
||||||
|
|
||||||
|
const auth = data.auth;
|
||||||
|
const core = data.core;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users, undefined, undefined, 'TOTAL_USERS'],
|
||||||
|
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2, undefined, undefined, 'STEAM_LINKED'],
|
||||||
|
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
|
||||||
|
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
|
||||||
|
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
|
||||||
|
[t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
|
||||||
|
[t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
|
||||||
|
[t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatDrillProvider source="community">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
{isLoading
|
||||||
|
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
||||||
|
: stats.map(([label, value, icon, suffix, key, usersKey]) => (
|
||||||
|
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}
|
||||||
|
drill={key && {params: {key}}}
|
||||||
|
onClick={active && usersKey && value > 0
|
||||||
|
? () => setPeople({title: label, key: usersKey}) : undefined}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
{core?.topPlayedGames?.length > 0 && (
|
||||||
|
<RankedGames
|
||||||
|
title={t('LANDING.TOP_PLAYED')}
|
||||||
|
games={core.topPlayedGames}
|
||||||
|
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
|
||||||
|
drillKey="MOST_PLAYED"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{core?.topOwnedGames?.length > 0 && (
|
||||||
|
<RankedGames
|
||||||
|
title={t('LANDING.TOP_OWNED')}
|
||||||
|
games={core.topOwnedGames}
|
||||||
|
formatValue={(count) => count.toLocaleString(i18n.language)}
|
||||||
|
drillKey="OWNED"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommunityBars core={core}/>
|
||||||
|
</div>
|
||||||
|
{people && (
|
||||||
|
<PeopleDialog title={people.title} onClose={() => setPeople(null)}
|
||||||
|
load={(page) => fetchUsers({key: people.key, page})}/>
|
||||||
|
)}
|
||||||
|
</StatDrillProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import {canCorrect} from '../utils/corrections.js';
|
|||||||
import {fetchLinkQueue} from '../utils/links.js';
|
import {fetchLinkQueue} from '../utils/links.js';
|
||||||
|
|
||||||
export const AUTO_CLOSE_MS = 30_000;
|
export const AUTO_CLOSE_MS = 30_000;
|
||||||
|
// Getting-started hints stay until the user closes them.
|
||||||
|
const STICKY_HINTS = ['NO_STEAM', 'EMPTY_LIBRARY'];
|
||||||
const CLOSED_KEY = 'tbp_closed_hints';
|
const CLOSED_KEY = 'tbp_closed_hints';
|
||||||
|
|
||||||
// Hints closed in this tab, so going back to the dashboard does not show them again.
|
// Hints closed in this tab, so going back to the dashboard does not show them again.
|
||||||
@@ -29,8 +31,8 @@ const rememberClosed = (hintId) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The hints on the dashboard, one at a time: the admin hints newest first, then the automatic ones. A DISMISS hint
|
* The hints on the dashboard, one at a time: the admin hints newest first, then the automatic ones. A DISMISS hint
|
||||||
* closed by the 30 s timer without "Do not show again" comes back in the next browser session. The X on an automatic
|
* closed by the 30 s timer without "Do not show again" comes back in the next browser session; STICKY_HINTS have no
|
||||||
* hint hides it for 7 days, "Do not show again" for good.
|
* timer. The X on an automatic hint hides it for 7 days, "Do not show again" for good.
|
||||||
*
|
*
|
||||||
* @param user the current user
|
* @param user the current user
|
||||||
* @param canSync a Steam ID or a PlayStation link: then the NO_STEAM hint has nothing to say; undefined while loading
|
* @param canSync a Steam ID or a PlayStation link: then the NO_STEAM hint has nothing to say; undefined while loading
|
||||||
@@ -105,7 +107,7 @@ export default function DashboardHint({user, canSync, summary, trend}) {
|
|||||||
}, [hint, t]);
|
}, [hint, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hint?.kind !== 'DISMISS') return undefined;
|
if (hint?.kind !== 'DISMISS' || STICKY_HINTS.includes(hint.auto)) return undefined;
|
||||||
const timer = setTimeout(() => close(hideForever, false), AUTO_CLOSE_MS);
|
const timer = setTimeout(() => close(hideForever, false), AUTO_CLOSE_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [hint, hideForever, close]);
|
}, [hint, hideForever, close]);
|
||||||
@@ -117,6 +119,6 @@ export default function DashboardHint({user, canSync, summary, trend}) {
|
|||||||
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
|
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
|
||||||
onClose={(event) => event.detail < 2 && close(hideForever, true)}
|
onClose={(event) => event.detail < 2 && close(hideForever, true)}
|
||||||
onAccept={(event) => event.detail < 2 && close(true, true)}
|
onAccept={(event) => event.detail < 2 && close(true, true)}
|
||||||
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/>
|
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md max-md:bottom-20"/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,8 @@ const GREW = {days: [{day: '2026-10-01', backlogGames: 10}, {day: '2026-10-02',
|
|||||||
|
|
||||||
const hintRoutes = (list, done = json(204), more = {}) => fetchRoutes({
|
const hintRoutes = (list, done = json(204), more = {}) => fetchRoutes({
|
||||||
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
|
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
|
||||||
'PUT /api/hints/auto/NO_STEAM/snooze': done, 'PUT /api/hints/auto/NO_STEAM/done': done, ...more,
|
'PUT /api/hints/auto/NO_STEAM/snooze': done, 'PUT /api/hints/auto/NO_STEAM/done': done,
|
||||||
|
'PUT /api/hints/auto/PILE_GREW/done': done, 'PUT /api/hints/auto/EMPTY_LIBRARY/snooze': done, ...more,
|
||||||
});
|
});
|
||||||
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
|
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ describe('DashboardHint, automatic hints', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not store the auto close of an automatic hint and shows it again next session', async () => {
|
it('does not store the auto close of an automatic hint and shows it again next session', async () => {
|
||||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
|
await showAuto(['PILE_GREW'], {trend: GREW});
|
||||||
|
|
||||||
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
||||||
|
|
||||||
@@ -201,17 +202,30 @@ describe('DashboardHint, automatic hints', () => {
|
|||||||
expect(doneCalls()).toEqual([]);
|
expect(doneCalls()).toEqual([]);
|
||||||
cleanup();
|
cleanup();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
|
await showAuto(['PILE_GREW'], {trend: GREW});
|
||||||
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
|
expect(screen.getByText('HINT.AUTO_PILE_GREW:2')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the auto closed hint for good when the tick is set', async () => {
|
it('hides the auto closed hint for good when the tick is set', async () => {
|
||||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
|
await showAuto(['PILE_GREW'], {trend: GREW});
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
|
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
|
||||||
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
||||||
|
|
||||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
|
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/PILE_GREW/done')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['NO_STEAM', {user: {...USER, steamId: null}, canSync: false}],
|
||||||
|
['EMPTY_LIBRARY', {summary: {total: 0}}],
|
||||||
|
])('keeps the %s hint after the auto close time, it closes only by the user', async (key, props) => {
|
||||||
|
await showAuto([key], props);
|
||||||
|
|
||||||
|
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS * 2));
|
||||||
|
|
||||||
|
expect(screen.getByText(`HINT.AUTO_${key}`)).toBeTruthy();
|
||||||
|
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
|
||||||
|
expect(screen.queryByRole('status')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show an automatic hint that Auth did not send', async () => {
|
it('does not show an automatic hint that Auth did not send', async () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
|||||||
import {bannerEffect, lookClass} from '../utils/rewards.js';
|
import {bannerEffect, lookClass} from '../utils/rewards.js';
|
||||||
|
|
||||||
// canSync: a Steam ID or a PlayStation link; the sync controls follow it, the Steam ID badge only the Steam ID.
|
// canSync: a Steam ID or a PlayStation link; the sync controls follow it, the Steam ID badge only the Steam ID.
|
||||||
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, canSync, editing, onCustomize, equipped}) {
|
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, canSync, editing, onCustomize, equipped, returning}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {user: userData} = useCurrentUserContext();
|
const {user: userData} = useCurrentUserContext();
|
||||||
const hasSteamId = !!userData?.steamId;
|
const hasSteamId = !!userData?.steamId;
|
||||||
@@ -36,7 +36,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
|
<p className="text-sm font-medium text-muted-foreground">{t(returning ? 'DASHBOARD.WELCOME_BACK' : 'DASHBOARD.WELCOME')}</p>
|
||||||
<h1 className="@container mt-1 text-3xl md:text-4xl font-bold tracking-tight">
|
<h1 className="@container mt-1 text-3xl md:text-4xl font-bold tracking-tight">
|
||||||
<FitText text={[t('DASHBOARD.HELLO'), userData?.displayName ?? userData?.email?.split('@')[0], '👋'].filter(Boolean).join(' ')}/>
|
<FitText text={[t('DASHBOARD.HELLO'), userData?.displayName ?? userData?.email?.split('@')[0], '👋'].filter(Boolean).join(' ')}/>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -48,7 +48,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Button asChild variant="link" className="mt-2 h-auto p-0">
|
<Button asChild variant="link" className="mt-2 h-auto p-0">
|
||||||
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
|
<Link to="/settings?tab=connections"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -61,6 +61,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
|
|||||||
status={sync.status}
|
status={sync.status}
|
||||||
lastSyncedAt={sync.lastSyncedAt}
|
lastSyncedAt={sync.lastSyncedAt}
|
||||||
isRunning={sync.isRunning}
|
isRunning={sync.isRunning}
|
||||||
|
queued={sync.queued}
|
||||||
stale={sync.stale}
|
stale={sync.stale}
|
||||||
/>
|
/>
|
||||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {toast} from 'sonner';
|
|||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import GameStatusBadge from './GameStatusBadge.jsx';
|
import GameStatusBadge from './GameStatusBadge.jsx';
|
||||||
|
import UnlinkDialog from './UnlinkDialog.jsx';
|
||||||
import FitText from './FitText.jsx';
|
import FitText from './FitText.jsx';
|
||||||
import {splitGame, unlinkGame} from '../utils/links.js';
|
import {splitGame, unlinkGame} from '../utils/links.js';
|
||||||
import {platformLabel} from '../utils/platforms.js';
|
import {platformLabel} from '../utils/platforms.js';
|
||||||
@@ -65,16 +66,14 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
|
|||||||
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
|
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
|
||||||
</Button>
|
</Button>
|
||||||
{game.editions?.length > 0 && (
|
{game.editions?.length > 0 && (
|
||||||
<Button type="button" variant="outline" size="sm" disabled={busy}
|
<UnlinkDialog size="sm" icon={Unlink} label={t('LINKS.SPLIT')} title={t('LINKS.SPLIT_CONFIRM_TITLE')}
|
||||||
onClick={() => run(splitGame, 'LINKS.SPLIT_DONE')}>
|
description={t('LINKS.SPLIT_CONFIRM_TEXT')} confirmLabel={t('LINKS.SPLIT')} disabled={busy}
|
||||||
<Unlink/>{t('LINKS.SPLIT')}
|
onConfirm={() => run(splitGame, 'LINKS.SPLIT_DONE')}/>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{auditor && game.linked && (
|
{auditor && game.linked && (
|
||||||
<Button type="button" variant="outline" size="sm" disabled={busy}
|
<UnlinkDialog size="sm" icon={Link2Off} label={t('LINKS.UNLINK')} title={t('LINKS.UNLINK_CONFIRM_TITLE')}
|
||||||
onClick={() => run(unlinkGame, 'LINKS.UNLINKED')}>
|
description={t('LINKS.UNLINK_CONFIRM_TEXT')} confirmLabel={t('LINKS.UNLINK')} disabled={busy}
|
||||||
<Link2Off/>{t('LINKS.UNLINK')}
|
onConfirm={() => run(unlinkGame, 'LINKS.UNLINKED')}/>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import EditionsCard from './EditionsCard.jsx';
|
import EditionsCard from './EditionsCard.jsx';
|
||||||
import {splitGame} from '../utils/links.js';
|
import {splitGame, unlinkGame} from '../utils/links.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -16,6 +16,11 @@ const renderCard = (game, onChanged = vi.fn()) => {
|
|||||||
return onChanged;
|
return onChanged;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmSplit = () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'LINKS.SPLIT'}));
|
||||||
|
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'LINKS.SPLIT'}));
|
||||||
|
};
|
||||||
|
|
||||||
describe('EditionsCard', () => {
|
describe('EditionsCard', () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -33,11 +38,21 @@ describe('EditionsCard', () => {
|
|||||||
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy();
|
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('asksBeforeSplittingAndSendsNothingOnCancel', () => {
|
||||||
|
renderCard({appid: 220, editions: [edition]});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'LINKS.SPLIT'}));
|
||||||
|
|
||||||
|
expect(screen.getByText('LINKS.SPLIT_CONFIRM_TITLE')).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
|
||||||
|
expect(splitGame).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('splitsTheGameAndReloads', async () => {
|
it('splitsTheGameAndReloads', async () => {
|
||||||
splitGame.mockResolvedValue({ok: true, status: 204});
|
splitGame.mockResolvedValue({ok: true, status: 204});
|
||||||
const onChanged = renderCard({appid: 220, editions: [edition]});
|
const onChanged = renderCard({appid: 220, editions: [edition]});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('LINKS.SPLIT'));
|
confirmSplit();
|
||||||
|
|
||||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||||
expect(splitGame).toHaveBeenCalledWith(220);
|
expect(splitGame).toHaveBeenCalledWith(220);
|
||||||
@@ -48,9 +63,24 @@ describe('EditionsCard', () => {
|
|||||||
splitGame.mockResolvedValue({ok: false, status: 404});
|
splitGame.mockResolvedValue({ok: false, status: 404});
|
||||||
const onChanged = renderCard({appid: 220, editions: [edition]});
|
const onChanged = renderCard({appid: 220, editions: [edition]});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('LINKS.SPLIT'));
|
confirmSplit();
|
||||||
|
|
||||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
|
||||||
expect(onChanged).not.toHaveBeenCalled();
|
expect(onChanged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('asksBeforeUnlinkingAndUnlinksOnConfirm', async () => {
|
||||||
|
unlinkGame.mockResolvedValue({ok: true, status: 204});
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
render(<MemoryRouter><EditionsCard game={{appid: 220, linked: true, editions: []}} auditor onRequest={vi.fn()}
|
||||||
|
onChanged={onChanged}/></MemoryRouter>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'LINKS.UNLINK'}));
|
||||||
|
expect(unlinkGame).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'LINKS.UNLINK'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||||
|
expect(unlinkGame).toHaveBeenCalledWith(220);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Library} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import HintText from './HintText.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dashboard's answer to an empty library.
|
||||||
|
*
|
||||||
|
* @param reason 'NO_SOURCE' (nothing linked yet) or 'STEAM_PRIVATE' (Steam is linked and synced, but found no games)
|
||||||
|
*/
|
||||||
|
export default function EmptyLibraryCard({reason}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<Card className="items-center gap-3 p-6 text-center">
|
||||||
|
<Library className="size-10 text-muted-foreground"/>
|
||||||
|
<h2 className="font-semibold">{t('DASHBOARD.EMPTY.TITLE')}</h2>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
|
{reason === 'STEAM_PRIVATE'
|
||||||
|
? <HintText text={t('LIBRARY.EMPTY_PRIVATE')}/>
|
||||||
|
: t('DASHBOARD.EMPTY.NO_SOURCE')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{reason === 'NO_SOURCE' && (
|
||||||
|
<Button asChild><Link to="/settings?tab=connections">{t('DASHBOARD.EMPTY.LINK')}</Link></Button>
|
||||||
|
)}
|
||||||
|
<Button asChild variant={reason === 'NO_SOURCE' ? 'outline' : 'default'}>
|
||||||
|
<Link to="/import">{t('DASHBOARD.EMPTY.IMPORT')}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {readFileSync} from 'node:fs';
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import EmptyLibraryCard from './EmptyLibraryCard.jsx';
|
||||||
|
|
||||||
|
// The real English text, so the test sees the privacy link.
|
||||||
|
const texts = JSON.parse(readFileSync('public/i18n/en.json', 'utf-8'));
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({t: (key) => key.split('.').reduce((node, part) => node?.[part], texts) ?? key}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderCard = (reason) => render(<MemoryRouter><EmptyLibraryCard reason={reason}/></MemoryRouter>);
|
||||||
|
|
||||||
|
describe('EmptyLibraryCard', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('NO_SOURCE offers to link a platform and to import', () => {
|
||||||
|
renderCard('NO_SOURCE');
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', {name: 'Link Steam / PlayStation'}).getAttribute('href')).toBe('/settings?tab=connections');
|
||||||
|
expect(screen.getByRole('link', {name: 'Import games'}).getAttribute('href')).toBe('/import');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('STEAM_PRIVATE links the Steam privacy page and still offers the import', () => {
|
||||||
|
renderCard('STEAM_PRIVATE');
|
||||||
|
|
||||||
|
const link = screen.getByRole('link', {name: 'Steam privacy settings'});
|
||||||
|
expect(link.getAttribute('href')).toBe('https://steamcommunity.com/my/edit/settings');
|
||||||
|
expect(screen.getByRole('link', {name: 'Import games'})).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('link', {name: 'Link Steam / PlayStation'})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ArrowRight, UserCheck} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import FollowingList from './people/FollowingList.jsx';
|
||||||
|
import useFollowing from '../hooks/useFollowing.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard tile: the first people of the following list. Loads its own data, so a dashboard without the tile pays nothing.
|
||||||
|
*
|
||||||
|
* @param limit how many people the tile size shows
|
||||||
|
*/
|
||||||
|
export default function FollowingCard({limit}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {following, failed, unfollow} = useFollowing();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="gap-0 py-0">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-3 border-b px-5 py-4">
|
||||||
|
<h2 className="flex items-center gap-2 font-semibold">
|
||||||
|
<UserCheck className="size-4 text-primary"/>
|
||||||
|
{t('FOLLOW.TITLE')}
|
||||||
|
</h2>
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link to="/community?tab=following">{t('FOLLOW.SHOW_ALL')}<ArrowRight/></Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-2">
|
||||||
|
{failed && <p className="py-3 text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
|
||||||
|
{following && <FollowingList entries={following} limit={limit} onUnfollow={unfollow} compact/>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, screen} from '@testing-library/react';
|
||||||
|
import FollowingCard from './FollowingCard.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('FollowingCard', () => {
|
||||||
|
it('showsAtMostTheLimitOfPeopleAndLinksToTheFollowingPage', async () => {
|
||||||
|
const following = ['A', 'B', 'C', 'D'].map((displayName, i) => ({id: i, displayName, lastActivityAt: null}));
|
||||||
|
fetchRoutes({'GET /api/follows': json(200, {followers: [], following})});
|
||||||
|
renderWithProviders(<FollowingCard limit={3}/>);
|
||||||
|
|
||||||
|
await screen.findByRole('link', {name: 'A'});
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', {name: 'D'})).toBeNull();
|
||||||
|
expect(screen.getAllByRole('button', {name: 'FOLLOW.UNFOLLOW'})).toHaveLength(3);
|
||||||
|
expect(screen.getByRole('link', {name: /FOLLOW.SHOW_ALL/}).getAttribute('href')).toBe('/community?tab=following');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheEmptyStateWhenNobodyIsFollowed', async () => {
|
||||||
|
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
|
||||||
|
renderWithProviders(<FollowingCard limit={3}/>);
|
||||||
|
|
||||||
|
expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
|
||||||
import {Link} from 'react-router-dom';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
import {Users} from 'lucide-react';
|
|
||||||
import {toast} from 'sonner';
|
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
|
||||||
import {fetchFollows, removeFollow} from '../utils/follows.js';
|
|
||||||
import {profileHref} from '../utils/people.js';
|
|
||||||
|
|
||||||
export default function FollowsCard() {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [follows, setFollows] = useState(null);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
const {ok, data} = await fetchFollows();
|
|
||||||
setFailed(!ok);
|
|
||||||
if (ok) setFollows(data);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const end = async (id) => {
|
|
||||||
const {ok, status} = await removeFollow(id);
|
|
||||||
if (!ok && status !== 401) toast.error(t('FOLLOW.ERROR'));
|
|
||||||
await load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const link = (displayName) => (
|
|
||||||
<Link to={profileHref(displayName)} className="min-w-0 truncate hover:text-primary hover:underline">{displayName}</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><Users className="size-4 text-primary"/>{t('FOLLOW.TITLE')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{failed && <p className="text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
|
|
||||||
{follows && (
|
|
||||||
<>
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="text-sm font-medium">{t('FOLLOW.FOLLOWING')}</h3>
|
|
||||||
{follows.following.length === 0 && <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWING')}</p>}
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{follows.following.map((entry) => (
|
|
||||||
<li key={entry.id} className="flex items-center justify-between gap-2 text-sm">
|
|
||||||
{link(entry.displayName)}
|
|
||||||
<Button variant="outline" size="sm" onClick={() => end(entry.id)}>{t('FOLLOW.UNFOLLOW')}</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h3 className="text-sm font-medium">{t('FOLLOW.FOLLOWERS')}</h3>
|
|
||||||
{follows.followers.length === 0 && <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWERS')}</p>}
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{follows.followers.map((entry) => (
|
|
||||||
<li key={entry.id} className="flex items-center justify-between gap-2 text-sm">
|
|
||||||
{entry.displayName
|
|
||||||
? link(entry.displayName)
|
|
||||||
: <span className="min-w-0 truncate text-muted-foreground">{t('FOLLOW.PRIVATE_USER')}</span>}
|
|
||||||
<Button variant="outline" size="sm" onClick={() => end(entry.id)}>{t('FOLLOW.REMOVE')}</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
|
||||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
|
||||||
import FollowsCard from './FollowsCard.jsx';
|
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
|
||||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const follows = {following: [{id: 5, displayName: 'Lobo'}], followers: [{id: 6, displayName: 'Wolf'}, {id: 7, displayName: null}]};
|
|
||||||
|
|
||||||
describe('FollowsCard', () => {
|
|
||||||
it('listsBothSidesWithLinksAndPrivateFollowersWithoutAName', async () => {
|
|
||||||
fetchRoutes({'GET /api/follows': json(200, follows)});
|
|
||||||
renderWithProviders(<FollowsCard/>);
|
|
||||||
|
|
||||||
expect((await screen.findByRole('link', {name: 'Lobo'})).getAttribute('href')).toBe('/u/Lobo');
|
|
||||||
expect(screen.getByRole('link', {name: 'Wolf'}).getAttribute('href')).toBe('/u/Wolf');
|
|
||||||
expect(screen.getByText('FOLLOW.PRIVATE_USER')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unfollowsByRowIdAndLoadsTheListAgain', async () => {
|
|
||||||
let answered = follows;
|
|
||||||
fetchRoutes({'GET /api/follows': () => json(200, answered),
|
|
||||||
'DELETE /api/follows/5': () => {
|
|
||||||
answered = {...follows, following: []};
|
|
||||||
return json(204);
|
|
||||||
}});
|
|
||||||
renderWithProviders(<FollowsCard/>);
|
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'FOLLOW.UNFOLLOW'}));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByRole('link', {name: 'Lobo'})).toBeNull());
|
|
||||||
expect(screen.getByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removesAFollowerByRowId', async () => {
|
|
||||||
fetchRoutes({'GET /api/follows': json(200, follows), 'DELETE /api/follows/7': json(204)});
|
|
||||||
renderWithProviders(<FollowsCard/>);
|
|
||||||
|
|
||||||
fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.REMOVE'}))[1]);
|
|
||||||
|
|
||||||
await waitFor(() => expect(fetch.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('saysSoWhenTheListDoesNotLoad', async () => {
|
|
||||||
fetchRoutes({'GET /api/follows': json(500, {})});
|
|
||||||
renderWithProviders(<FollowsCard/>);
|
|
||||||
|
|
||||||
expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,13 +2,15 @@ import {Label} from '@/components/ui/label';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Label, input and the translated validation error of one form field.
|
* Label, input and the translated validation error of one form field.
|
||||||
* `error` is a react-hook-form field error whose message is an i18n key.
|
* `error` is a react-hook-form field error whose message is an i18n key. `description` is helper text under the input;
|
||||||
|
* the input points to it with aria-describedby="<id>-description".
|
||||||
*/
|
*/
|
||||||
export default function FormField({id, label, error, t, children}) {
|
export default function FormField({id, label, error, description, t, children}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={id}>{label}</Label>
|
<Label htmlFor={id}>{label}</Label>
|
||||||
{children}
|
{children}
|
||||||
|
{description && <p id={`${id}-description`} className="text-xs text-muted-foreground">{description}</p>}
|
||||||
{error && <p className="text-sm text-destructive">{t(error.message)}</p>}
|
{error && <p className="text-sm text-destructive">{t(error.message)}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} from 'lucide-react';
|
import {Clock, Ellipsis, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import FitText from './FitText.jsx';
|
import FitText from './FitText.jsx';
|
||||||
import ComingBadge from './ComingBadge.jsx';
|
import ComingBadge from './ComingBadge.jsx';
|
||||||
import GameStatusBadge from './GameStatusBadge.jsx';
|
import ProfileArtItems from './ProfileArtItems.jsx';
|
||||||
import ProfileArtMenu from './ProfileArtMenu.jsx';
|
import RatingStars from './RatingStars.jsx';
|
||||||
|
import StatusMenu from './StatusMenu.jsx';
|
||||||
import TimeToBeat from './TimeToBeat.jsx';
|
import TimeToBeat from './TimeToBeat.jsx';
|
||||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||||
|
import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
|
||||||
|
import useSetStatus from '../hooks/useSetStatus.js';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
|
import {UNDO_MS} from '../utils/backlog.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {setGameHidden} from '../utils/hiddenGames.js';
|
import {setGameHidden} from '../utils/hiddenGames.js';
|
||||||
import {isSteamKey} from '../utils/manualGames.js';
|
import {isSteamKey} from '../utils/manualGames.js';
|
||||||
@@ -25,11 +36,19 @@ const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
|||||||
// The spans of each completed effect decoration; effects.css places them.
|
// The spans of each completed effect decoration; effects.css places them.
|
||||||
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6, 'fx-detail-platinum': 6};
|
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6, 'fx-detail-platinum': 6};
|
||||||
|
|
||||||
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
|
/**
|
||||||
|
* Status and rating are edited here and save at once. A status change needs no points check: GameDetail's
|
||||||
|
* GamePointsLine sees the new status and does it.
|
||||||
|
*
|
||||||
|
* @param onSaved gets the saved backlog entry after a status or rating change
|
||||||
|
*/
|
||||||
|
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved, onSaved}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {appearance, equipped} = usePreferences();
|
const {appearance, equipped} = usePreferences();
|
||||||
const saveAppearance = useSaveAppearance();
|
const saveAppearance = useSaveAppearance();
|
||||||
const [hiding, setHiding] = useState(false);
|
const [hiding, setHiding] = useState(false);
|
||||||
|
const {setStatus, busy: statusBusy} = useSetStatus(game.appid, {onChanged: onSaved});
|
||||||
|
const {save, pending, busy: ratingBusy} = useSaveBacklogField(game.appid, {onSaved});
|
||||||
|
|
||||||
const onSteam = isSteamKey(game.appid);
|
const onSteam = isSteamKey(game.appid);
|
||||||
// Core checks the link too; a link from the API must never run script.
|
// Core checks the link too; a link from the API must never run script.
|
||||||
@@ -39,8 +58,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
|||||||
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
|
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
|
||||||
const detailFx = detailEffect(equipped, game.status);
|
const detailFx = detailEffect(equipped, game.status);
|
||||||
|
|
||||||
const toggleHidden = async () => {
|
const applyHidden = async (hidden, {undoable = false} = {}) => {
|
||||||
const hidden = !game.hidden;
|
|
||||||
setHiding(true);
|
setHiding(true);
|
||||||
try {
|
try {
|
||||||
const response = await setGameHidden(game.appid, hidden);
|
const response = await setGameHidden(game.appid, hidden);
|
||||||
@@ -50,7 +68,12 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onHiddenChanged(hidden);
|
onHiddenChanged(hidden);
|
||||||
toast.success(t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST'));
|
const message = t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST');
|
||||||
|
if (undoable) {
|
||||||
|
toast.success(message, {duration: UNDO_MS, action: {label: t('MANUAL_GAME.UNDO'), onClick: () => applyHidden(!hidden)}});
|
||||||
|
} else {
|
||||||
|
toast.success(message);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Hide game error:', err);
|
console.error('Hide game error:', err);
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
@@ -79,11 +102,17 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
|||||||
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
|
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
|
||||||
</Badge>
|
</Badge>
|
||||||
<ComingBadge game={game}/>
|
<ComingBadge game={game}/>
|
||||||
<GameStatusBadge status={game.status}/>
|
<StatusMenu status={game.status} busy={statusBusy}
|
||||||
|
onSelect={(status) => setStatus(status, {previous: game.status})}/>
|
||||||
{platformLabel(game) && (
|
{platformLabel(game) && (
|
||||||
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
|
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<span id="hero-rating" className="sr-only">{t('BACKLOG.RATING_LABEL')}</span>
|
||||||
|
<RatingStars value={pending?.field === 'rating' ? pending.value : (game.rating ?? 0)}
|
||||||
|
onChange={(value) => save('rating', value)} labelledBy="hero-rating" disabled={ratingBusy}/>
|
||||||
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<TimeToBeat time={game.timeToBeat}/>
|
<TimeToBeat time={game.timeToBeat}/>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,52 +123,56 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
|
|||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{auditor && (
|
|
||||||
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={onCorrect}>
|
|
||||||
<PencilLine/>{t('CORRECTION.OPEN')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{auditor && (
|
|
||||||
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={onMerge}>
|
|
||||||
<Merge/>{t('MERGE.OPEN')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
|
{/* Background, banner and profile art need Steam art, so a game off Steam has none of them. */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{onSteam && (<>
|
{onSteam && (
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
|
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
|
||||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
{isBackground ? (
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
<DropdownMenu>
|
||||||
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
<DropdownMenuTrigger asChild>
|
||||||
</Button>
|
<Button variant="outline" size="icon-sm" aria-label={t('GAME_DETAIL.MORE')}><Ellipsis/></Button>
|
||||||
) : (
|
</DropdownMenuTrigger>
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: game.appid})}>
|
<DropdownMenuContent align="start">
|
||||||
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
{onSteam && (<>
|
||||||
</Button>
|
{isBackground ? (
|
||||||
)}
|
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: null})}>
|
||||||
{isBanner ? (
|
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
</DropdownMenuItem>
|
||||||
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
) : (
|
||||||
</Button>
|
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: game.appid})}>
|
||||||
) : (
|
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
||||||
<Button variant="outline" size="sm"
|
</DropdownMenuItem>
|
||||||
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
)}
|
||||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
{isBanner ? (
|
||||||
</Button>
|
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: null})}>
|
||||||
)}
|
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
||||||
{profileStyle && (
|
</DropdownMenuItem>
|
||||||
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
) : (
|
||||||
)}
|
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
||||||
</>)}
|
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
</DropdownMenuItem>
|
||||||
{game.hidden ? <Eye/> : <EyeOff/>}
|
)}
|
||||||
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
{profileStyle && (
|
||||||
</Button>
|
<ProfileArtItems appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator/>
|
||||||
|
</>)}
|
||||||
|
<DropdownMenuItem disabled={hiding} onSelect={() => applyHidden(!game.hidden, {undoable: true})}>
|
||||||
|
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||||
|
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{auditor && (<>
|
||||||
|
<DropdownMenuSeparator/>
|
||||||
|
<DropdownMenuItem onSelect={onCorrect}><PencilLine/>{t('CORRECTION.OPEN')}</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={onMerge}><Merge/>{t('MERGE.OPEN')}</DropdownMenuItem>
|
||||||
|
</>)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import GameHeroCard from './GameHeroCard.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const game = {appid: 220, name: 'Half-Life 2', status: 'BACKLOG', rating: null, hidden: false, playtimeMinutes: 10};
|
||||||
|
|
||||||
|
const routes = (extra = {}) => fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
|
||||||
|
'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
|
||||||
|
'PUT /api/library/games/220/hidden': (url, init) => json(200, JSON.parse(init.body)),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderCard = (props = {}) => renderWithProviders(
|
||||||
|
<GameHeroCard game={game} auditor={false} onCorrect={vi.fn()} onMerge={vi.fn()} onHiddenChanged={vi.fn()}
|
||||||
|
onProfileStyleSaved={vi.fn()} onSaved={vi.fn()} {...props}/>);
|
||||||
|
|
||||||
|
const openMore = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'}), {key: 'Enter'});
|
||||||
|
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GameHeroCard', () => {
|
||||||
|
it('keepsTheMoreMenuForArtHideAndAuditorTools', async () => {
|
||||||
|
routes();
|
||||||
|
renderCard({auditor: true, profileStyle: {}});
|
||||||
|
expect(screen.getByRole('link', {name: /GAME_DETAIL.OPEN_IN_STEAM/})).toBeTruthy();
|
||||||
|
|
||||||
|
await openMore();
|
||||||
|
|
||||||
|
const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
|
||||||
|
expect(names).toEqual(['GAME_DETAIL.SET_BACKGROUND', 'GAME_DETAIL.SET_BANNER', 'GAME_DETAIL.HIDE', 'CORRECTION.OPEN', 'MERGE.OPEN']);
|
||||||
|
expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2);
|
||||||
|
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offersNoAuditorToolsToAUser', async () => {
|
||||||
|
routes();
|
||||||
|
renderCard();
|
||||||
|
|
||||||
|
await openMore();
|
||||||
|
|
||||||
|
const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
|
||||||
|
expect(names).not.toContain('CORRECTION.OPEN');
|
||||||
|
expect(names).not.toContain('MERGE.OPEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidesWithAnUndoThatShowsTheGameAgain', async () => {
|
||||||
|
routes();
|
||||||
|
const onHiddenChanged = vi.fn();
|
||||||
|
renderCard({onHiddenChanged});
|
||||||
|
await openMore();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('menuitem', {name: 'GAME_DETAIL.HIDE'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onHiddenChanged).toHaveBeenCalledWith(true));
|
||||||
|
const [message, options] = toast.success.mock.calls.at(-1);
|
||||||
|
expect(message).toBe('GAME_DETAIL.HIDDEN_TOAST');
|
||||||
|
expect(options.action.label).toBe('MANUAL_GAME.UNDO');
|
||||||
|
|
||||||
|
await act(async () => options.action.onClick());
|
||||||
|
|
||||||
|
expect(onHiddenChanged).toHaveBeenLastCalledWith(false);
|
||||||
|
expect(calls('PUT').map(([, init]) => JSON.parse(init.body))).toEqual([{hidden: true}, {hidden: false}]);
|
||||||
|
// The undo is a plain toast.
|
||||||
|
expect(toast.success.mock.calls.at(-1)).toEqual(['GAME_DETAIL.SHOWN_TOAST']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('savesTheStatusFromTheMenuWithoutAskingThePointsItself', async () => {
|
||||||
|
routes();
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
renderCard({onSaved});
|
||||||
|
|
||||||
|
fireEvent.keyDown(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}), {key: 'Enter'});
|
||||||
|
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(expect.objectContaining({status: 'PLAYING'})));
|
||||||
|
expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({status: 'PLAYING'});
|
||||||
|
// GameDetail's GamePointsLine does the points check when the status changes; none here (no route would fail).
|
||||||
|
expect(toast.success.mock.calls.at(-1)[1].action.label).toBe('MANUAL_GAME.UNDO');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('savesTheRatingAtOnce', async () => {
|
||||||
|
routes();
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
renderCard({onSaved});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||||
|
expect(calls('PATCH').map(([, init]) => JSON.parse(init.body))).toEqual([{rating: 4}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import {Badge} from '@/components/ui/badge';
|
|||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import FitText from './FitText.jsx';
|
import FitText from './FitText.jsx';
|
||||||
import {searchGames} from '../utils/manualGames.js';
|
import {hitTitle, searchGames} from '../utils/manualGames.js';
|
||||||
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
|
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 400;
|
const SEARCH_DEBOUNCE_MS = 400;
|
||||||
@@ -17,9 +17,11 @@ const SEARCH_DEBOUNCE_MS = 400;
|
|||||||
*
|
*
|
||||||
* @param hide hits not offered at all
|
* @param hide hits not offered at all
|
||||||
* @param blockInLibrary hits already in the own library are shown, but cannot be picked
|
* @param blockInLibrary hits already in the own library are shown, but cannot be picked
|
||||||
|
* @param markInLibrary hits in the own library get the "In library" badge; defaults to blockInLibrary
|
||||||
* @param selected with onToggle: igdb ids checked for a pick of several games; each hit gets a checkbox
|
* @param selected with onToggle: igdb ids checked for a pick of several games; each hit gets a checkbox
|
||||||
*/
|
*/
|
||||||
export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false,
|
export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false,
|
||||||
|
markInLibrary = blockInLibrary,
|
||||||
selected, onToggle}) {
|
selected, onToggle}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {searchState, results} = useGameSearch(query);
|
const {searchState, results} = useGameSearch(query);
|
||||||
@@ -62,13 +64,13 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
|
|||||||
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
|
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
|
||||||
<span className="@container min-w-0 flex-1">
|
<span className="@container min-w-0 flex-1">
|
||||||
<span className="block font-medium">
|
<span className="block font-medium">
|
||||||
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
|
<FitText text={hitTitle(hit)}/>
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-xs text-muted-foreground">
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
{hit.platforms.join(' · ')}
|
{hit.platforms.join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{blocked && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
|
{markInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import {Badge} from '@/components/ui/badge';
|
|||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
export const STATUS_META = {
|
export const STATUS_META = {
|
||||||
BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'},
|
BACKLOG: {icon: Inbox, color: 'var(--color-zinc-400)', text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'},
|
||||||
PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'},
|
PLAYING: {icon: Gamepad2, color: 'var(--primary)', text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'},
|
||||||
FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'},
|
FINISHED: {icon: CircleCheck, color: 'var(--color-emerald-500)', text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'},
|
||||||
COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'},
|
COMPLETED: {icon: Trophy, color: 'var(--color-amber-500)', text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'},
|
||||||
DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'},
|
DROPPED: {icon: Ban, color: 'var(--color-rose-500)', text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {NavLink} from 'react-router-dom';
|
import {useLocation} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Gift, Library, ListOrdered} from 'lucide-react';
|
import {Gift, Library, ListOrdered} from 'lucide-react';
|
||||||
|
|
||||||
import {cn} from '@/lib/utils';
|
import TabNav from './TabNav.jsx';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
|
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
|
||||||
@@ -12,28 +12,6 @@ const TABS = [
|
|||||||
|
|
||||||
export default function LibraryTabs() {
|
export default function LibraryTabs() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
return (
|
const {pathname} = useLocation();
|
||||||
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex max-w-full rounded-lg bg-muted p-1 text-sm">
|
return <TabNav label={t('LIBRARY.TITLE')} tabs={TABS.map((tab) => ({...tab, active: pathname === tab.to}))}/>;
|
||||||
{TABS.map(({to, labelKey, icon: Icon}) => (
|
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
end
|
|
||||||
title={t(labelKey)}
|
|
||||||
className={({isActive}) => cn(
|
|
||||||
'flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
|
|
||||||
isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({isActive}) => (
|
|
||||||
<>
|
|
||||||
<Icon className="size-4 shrink-0"/>
|
|
||||||
{/* Below sm only the active tab shows its label; the others keep it as their name. */}
|
|
||||||
<span className={cn('truncate', !isActive && 'sr-only sm:not-sr-only')}>{t(labelKey)}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,10 @@ import {Card} from '@/components/ui/card';
|
|||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {hoursToMinutes, removeGame, restoreGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
import {hoursToMinutes, removeGame, restoreGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||||
|
import {UNDO_MS} from '../utils/backlog.js';
|
||||||
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||||
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
|
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
|
||||||
|
|
||||||
const UNDO_MS = 8000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
|
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import {Input} from '@/components/ui/input';
|
|
||||||
import FormField from './FormField.jsx';
|
import FormField from './FormField.jsx';
|
||||||
|
import PasswordInput from './PasswordInput.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The new password and its repeat, for a form on confirmEmailSchema.
|
* The new password and its repeat, for a form on confirmEmailSchema. The rules are visible from the start.
|
||||||
*/
|
*/
|
||||||
export default function NewPasswordFields({register, errors, t}) {
|
export default function NewPasswordFields({register, errors, t}) {
|
||||||
return <>
|
return <>
|
||||||
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password} t={t}>
|
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password}
|
||||||
<Input id="password" type="password" autoComplete="new-password" aria-invalid={!!errors.password}
|
description={t('VALIDATION.PASSWORD_RULES')} t={t}>
|
||||||
{...register('password')}/>
|
<PasswordInput id="password" autoComplete="new-password" aria-invalid={!!errors.password}
|
||||||
|
aria-describedby="password-description" {...register('password')}/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField id="passwordConfirmation" label={t('REGISTRATION.PASSWORD_CONFIRMATION')}
|
<FormField id="passwordConfirmation" label={t('REGISTRATION.PASSWORD_CONFIRMATION')}
|
||||||
error={errors.passwordConfirmation} t={t}>
|
error={errors.passwordConfirmation} t={t}>
|
||||||
<Input id="passwordConfirmation" type="password" autoComplete="new-password"
|
<PasswordInput id="passwordConfirmation" autoComplete="new-password"
|
||||||
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
|
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import NewPasswordFields from './NewPasswordFields.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const register = (name) => ({name});
|
||||||
|
|
||||||
|
describe('NewPasswordFields', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('shows the password rules from the start and links them to the password input', () => {
|
||||||
|
render(<NewPasswordFields register={register} errors={{}} t={(key) => key}/>);
|
||||||
|
|
||||||
|
const rules = screen.getByText('VALIDATION.PASSWORD_RULES');
|
||||||
|
const input = screen.getByLabelText('REGISTRATION.PASSWORD');
|
||||||
|
expect(input.getAttribute('aria-describedby')).toBe(rules.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a show-password toggle on both fields', () => {
|
||||||
|
render(<NewPasswordFields register={register} errors={{}} t={(key) => key}/>);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('button', {name: 'LOGIN.SHOW_PASSWORD'})).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ChevronLeft, ChevronRight} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {pageNumbers} from '../utils/pagination.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous / page numbers / Next. Below the sm breakpoint only the current number shows.
|
||||||
|
*
|
||||||
|
* @param page current page, 0-based
|
||||||
|
* @param onChange gets the 0-based target page
|
||||||
|
* @param summary node shown after the buttons
|
||||||
|
*/
|
||||||
|
export default function Pagination({page, totalPages, onChange, summary}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<nav aria-label={t('LIBRARY.PAGINATION')} className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onChange(page - 1)} disabled={page <= 0}>
|
||||||
|
<ChevronLeft/>{t('LIBRARY.PREV')}
|
||||||
|
</Button>
|
||||||
|
{pageNumbers(page, totalPages).map((n, i) => (n === null
|
||||||
|
? <span key={`gap-${i}`} aria-hidden="true" className="hidden px-1 sm:inline">…</span>
|
||||||
|
: (
|
||||||
|
<Button key={n} size="sm" variant={n === page ? 'default' : 'outline'}
|
||||||
|
aria-current={n === page ? 'page' : undefined}
|
||||||
|
className={n === page ? 'tabular-nums' : 'hidden tabular-nums sm:inline-flex'}
|
||||||
|
onClick={() => onChange(n)}>
|
||||||
|
{n + 1}
|
||||||
|
</Button>
|
||||||
|
)))}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onChange(page + 1)} disabled={page >= totalPages - 1}>
|
||||||
|
{t('LIBRARY.NEXT')}<ChevronRight/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<span className="text-center">{summary}</span>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Eye, EyeOff} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A password input with a button that shows or hides the text. Every other prop, also a react-hook-form `register()`
|
||||||
|
* result with its ref, goes to the input.
|
||||||
|
*/
|
||||||
|
export default function PasswordInput({className, ...props}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input type={shown ? 'text' : 'password'} className={cn('pr-10', className)} {...props}/>
|
||||||
|
<Button type="button" variant="ghost" size="icon" aria-pressed={shown}
|
||||||
|
aria-label={t(shown ? 'LOGIN.HIDE_PASSWORD' : 'LOGIN.SHOW_PASSWORD')}
|
||||||
|
className="absolute right-1 top-1/2 size-7 -translate-y-1/2" onClick={() => setShown(!shown)}>
|
||||||
|
{shown ? <EyeOff/> : <Eye/>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
|
import PasswordInput from './PasswordInput.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
describe('PasswordInput', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('hides the text at first and toggles it with aria-pressed', () => {
|
||||||
|
render(<PasswordInput aria-label="pw"/>);
|
||||||
|
const input = screen.getByLabelText('pw');
|
||||||
|
expect(input.type).toBe('password');
|
||||||
|
expect(screen.getByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}).getAttribute('aria-pressed')).toBe('false');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}));
|
||||||
|
|
||||||
|
expect(input.type).toBe('text');
|
||||||
|
expect(screen.getByRole('button', {name: 'LOGIN.HIDE_PASSWORD'}).getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit the form with the toggle', () => {
|
||||||
|
const onSubmit = vi.fn((event) => event.preventDefault());
|
||||||
|
render(<form onSubmit={onSubmit}><PasswordInput aria-label="pw"/></form>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}));
|
||||||
|
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the other props to the input', () => {
|
||||||
|
render(<PasswordInput aria-label="pw" autoComplete="new-password" defaultValue="abc"/>);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('pw');
|
||||||
|
expect(input.getAttribute('autocomplete')).toBe('new-password');
|
||||||
|
expect(input.value).toBe('abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,7 @@ export default function PlayingHint({game, onChanged}) {
|
|||||||
{t('GAME_DETAIL.PLAYED_RECENTLY', {time: formatPlaytime(game.playtime2Weeks)})}
|
{t('GAME_DETAIL.PLAYED_RECENTLY', {time: formatPlaytime(game.playtime2Weeks)})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="self-end sm:self-auto" onClick={() => setStatus('PLAYING')} disabled={busy}>
|
<Button size="sm" className="self-end sm:self-auto" onClick={() => setStatus('PLAYING', {previous: 'BACKLOG'})} disabled={busy}>
|
||||||
{busy && <Loader2 className="animate-spin"/>}
|
{busy && <Loader2 className="animate-spin"/>}
|
||||||
{t('GAME_DETAIL.SET_PLAYING')}
|
{t('GAME_DETAIL.SET_PLAYING')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {SlidersHorizontal} from 'lucide-react';
|
import {SlidersHorizontal} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
|
||||||
|
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||||
import {Switch} from '@/components/ui/switch';
|
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import usePersistSettings from '../hooks/usePersistSettings.js';
|
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||||
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
|
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
|
||||||
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
import {LANGUAGES} from '../utils/settings.js';
|
||||||
|
|
||||||
// Radix Select has no empty value.
|
// Radix Select has no empty value.
|
||||||
const NO_FAVORITE = 'NONE';
|
const NO_FAVORITE = 'NONE';
|
||||||
@@ -19,26 +17,22 @@ const NO_FAVORITE = 'NONE';
|
|||||||
export default function PreferencesCard() {
|
export default function PreferencesCard() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
||||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
const {setLanguage} = usePreferences();
|
||||||
const persistSettings = usePersistSettings();
|
const persistSettings = usePersistSettings();
|
||||||
const runSave = useSaveResponse();
|
const runSave = useSaveResponse();
|
||||||
|
|
||||||
const handleDarkThemeToggle = async () => {
|
|
||||||
const next = !isDarkMode;
|
|
||||||
setDarkMode(next); // apply on the fly
|
|
||||||
await persistSettings(() => saveSettings({useDarkTheme: next}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLanguageChange = async (next) => {
|
const handleLanguageChange = async (next) => {
|
||||||
await persistSettings(() => setLanguage(next));
|
const previous = i18n.language;
|
||||||
|
const {ok} = await persistSettings(() => setLanguage(next), {silent: true});
|
||||||
|
if (!ok) i18n.changeLanguage(previous);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFavoritePlatformChange = async (value) => {
|
const handleFavoritePlatformChange = async (value) => {
|
||||||
const platform = value === NO_FAVORITE ? null : value;
|
const platform = value === NO_FAVORITE ? null : value;
|
||||||
if (await runSave(() => saveFavoritePlatform(platform))) {
|
const previous = profile?.settings?.favoritePlatform ?? null;
|
||||||
updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
|
const setPlatform = (next) => updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: next}}));
|
||||||
toast.success(t('SETTINGS.SAVED'));
|
setPlatform(platform);
|
||||||
}
|
if (!await runSave(() => saveFavoritePlatform(platform))) setPlatform(previous);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,10 +41,6 @@ export default function PreferencesCard() {
|
|||||||
<CardTitle className="flex items-center gap-2"><SlidersHorizontal className="size-4 text-primary"/>{t('SETTINGS.PREFERENCES')}</CardTitle>
|
<CardTitle className="flex items-center gap-2"><SlidersHorizontal className="size-4 text-primary"/>{t('SETTINGS.PREFERENCES')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<Label htmlFor="useDarkTheme">{t('SETTINGS.DARK_THEME')}</Label>
|
|
||||||
<Switch id="useDarkTheme" checked={isDarkMode} onCheckedChange={handleDarkThemeToggle}/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Label htmlFor="language">{t('SETTINGS.LANGUAGE')}</Label>
|
<Label htmlFor="language">{t('SETTINGS.LANGUAGE')}</Label>
|
||||||
<Select value={i18n.language} onValueChange={handleLanguageChange}>
|
<Select value={i18n.language} onValueChange={handleLanguageChange}>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {DropdownMenuCheckboxItem, DropdownMenuLabel} from '@/components/ui/dropdown-menu';
|
||||||
|
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu items to use the art of this Steam game as banner or background of the public profile; a checked item removes
|
||||||
|
* it again. Render inside a DropdownMenuContent.
|
||||||
|
*
|
||||||
|
* @param onSaved gets the whole saved style
|
||||||
|
*/
|
||||||
|
export default function ProfileArtItems({appid, style, onSaved}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const save = useSaveProfileStyle();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const toggle = async (field) => {
|
||||||
|
setSaving(true);
|
||||||
|
const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
|
||||||
|
if (saved) onSaved(saved);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>{t('GAME_DETAIL.PROFILE_ART')}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuCheckboxItem checked={style.bannerAppid === appid} disabled={saving}
|
||||||
|
onCheckedChange={() => toggle('bannerAppid')}>
|
||||||
|
{t('GAME_DETAIL.PROFILE_BANNER')}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem checked={style.backgroundAppid === appid} disabled={saving}
|
||||||
|
onCheckedChange={() => toggle('backgroundAppid')}>
|
||||||
|
{t('GAME_DETAIL.PROFILE_BACKGROUND')}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import {useState} from 'react';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
import {UserRound} from 'lucide-react';
|
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uses the art of this Steam game as banner or background of the public profile; a checked item removes it again.
|
|
||||||
*
|
|
||||||
* @param onSaved gets the whole saved style
|
|
||||||
*/
|
|
||||||
export default function ProfileArtMenu({appid, style, onSaved}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const save = useSaveProfileStyle();
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const toggle = async (field) => {
|
|
||||||
setSaving(true);
|
|
||||||
const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
|
|
||||||
if (saved) onSaved(saved);
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" disabled={saving}><UserRound/>{t('GAME_DETAIL.PROFILE_ART')}</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuCheckboxItem checked={style.bannerAppid === appid} onCheckedChange={() => toggle('bannerAppid')}>
|
|
||||||
{t('GAME_DETAIL.PROFILE_BANNER')}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
<DropdownMenuCheckboxItem checked={style.backgroundAppid === appid}
|
|
||||||
onCheckedChange={() => toggle('backgroundAppid')}>
|
|
||||||
{t('GAME_DETAIL.PROFILE_BACKGROUND')}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {useForm} from 'react-hook-form';
|
import {useForm} from 'react-hook-form';
|
||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {Copy, Gamepad2, Loader2, Save} from 'lucide-react';
|
import {Copy, Gamepad2, Loader2, Save} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
@@ -14,6 +13,8 @@ import FormField from './FormField.jsx';
|
|||||||
import ProfileStyleSettings from './ProfileStyleSettings.jsx';
|
import ProfileStyleSettings from './ProfileStyleSettings.jsx';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
import usePersistSettings from '../hooks/usePersistSettings.js';
|
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||||
|
import useCopyLink from '../hooks/useCopyLink.js';
|
||||||
|
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
||||||
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
||||||
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||||
import {saveOwnerLists, saveSettings} from '../utils/settings.js';
|
import {saveOwnerLists, saveSettings} from '../utils/settings.js';
|
||||||
@@ -25,32 +26,37 @@ export default function ProfileCard({displayName}) {
|
|||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
||||||
const persistSettings = usePersistSettings();
|
const persistSettings = usePersistSettings();
|
||||||
|
const copyProfileLink = useCopyLink();
|
||||||
const saveStyle = useSaveProfileStyle();
|
const saveStyle = useSaveProfileStyle();
|
||||||
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
|
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: {errors, isSubmitting},
|
reset,
|
||||||
|
formState: {errors, isSubmitting, isDirty},
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(settingsSchema),
|
resolver: zodResolver(settingsSchema),
|
||||||
defaultValues: {displayName},
|
defaultValues: {displayName},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePublicProfileToggle = async () => {
|
useUnsavedChanges(isDirty);
|
||||||
const next = !profile?.settings?.publicProfile;
|
|
||||||
const result = await persistSettings(() => saveSettings({publicProfile: next}));
|
const toggleSetting = async (key, next, save) => {
|
||||||
if (result.ok) {
|
const show = (value) => updateProfile((p) => ({...p, settings: {...p.settings, [key]: value}}));
|
||||||
updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
show(next);
|
||||||
}
|
const {ok} = await persistSettings(save, {silent: true});
|
||||||
|
if (!ok) show(!next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOwnerListsToggle = async () => {
|
const handlePublicProfileToggle = () => {
|
||||||
|
const next = !profile?.settings?.publicProfile;
|
||||||
|
return toggleSetting('publicProfile', next, () => saveSettings({publicProfile: next}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOwnerListsToggle = () => {
|
||||||
const next = !profile?.settings?.showInOwnerLists;
|
const next = !profile?.settings?.showInOwnerLists;
|
||||||
const result = await persistSettings(() => saveOwnerLists(next));
|
return toggleSetting('showInOwnerLists', next, () => saveOwnerLists(next));
|
||||||
if (result.ok) {
|
|
||||||
updateProfile((p) => ({...p, settings: {...p.settings, showInOwnerLists: next}}));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
|
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
|
||||||
@@ -63,21 +69,14 @@ export default function ProfileCard({displayName}) {
|
|||||||
setSavingProfileStyle(false);
|
setSavingProfileStyle(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyProfileLink = async (link) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(link);
|
|
||||||
toast.success(t('SETTINGS.PUBLIC_PROFILE_COPIED'));
|
|
||||||
} catch {
|
|
||||||
toast.error(link);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmitProfile = async (values) => {
|
const onSubmitProfile = async (values) => {
|
||||||
// Without a name the profile has no link, so removing the name also makes it private.
|
// Without a name the profile has no link, so removing the name also makes it private.
|
||||||
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
|
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
|
||||||
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
|
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
|
updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
|
||||||
|
// The saved name is the new default, so the unsaved-changes guard lets go.
|
||||||
|
reset({displayName: values.displayName});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,7 +145,7 @@ export default function ProfileCard({displayName}) {
|
|||||||
|
|
||||||
<div className="mt-6 border-t pt-6">
|
<div className="mt-6 border-t pt-6">
|
||||||
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
|
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
|
||||||
disabled={!publicProfile || savingProfileStyle}
|
privateProfile={!publicProfile} disabled={!publicProfile || savingProfileStyle}
|
||||||
onChange={handleProfileStyleChange}/>
|
onChange={handleProfileStyleChange}/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Trophy} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import FitText from './FitText.jsx';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import GameStatusBadge from './GameStatusBadge.jsx';
|
||||||
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
import {fetchProfileGame} from '../utils/people.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One game of a public profile, above its tiles. Shows what the profile shares of it; a game the user hides or does
|
||||||
|
* not have gets one message.
|
||||||
|
*/
|
||||||
|
export default function ProfileGameCard({ticket, appid}) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const [game, setGame] = useState(null);
|
||||||
|
const [missing, setMissing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setGame(null);
|
||||||
|
setMissing(false);
|
||||||
|
(async () => {
|
||||||
|
const {ok, data} = await fetchProfileGame(ticket, appid);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (ok) setGame(data);
|
||||||
|
else setMissing(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [ticket, appid]);
|
||||||
|
|
||||||
|
if (missing) {
|
||||||
|
return <Card className="p-5 text-sm text-muted-foreground">{t('PROFILE.GAME_NOT_SHOWN')}</Card>;
|
||||||
|
}
|
||||||
|
if (!game) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="gap-4 p-5 sm:flex-row sm:items-center">
|
||||||
|
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full sm:w-56 sm:shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="@container text-lg font-semibold"><FitText text={game.name ?? `App ${game.appid}`}/></div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
|
{game.status && <GameStatusBadge status={game.status}/>}
|
||||||
|
{game.playtimeMinutes != null && <span className="tabular-nums">{formatPlaytime(game.playtimeMinutes)}</span>}
|
||||||
|
{game.achievementsTotal != null && (
|
||||||
|
<span className="flex items-center gap-1 tabular-nums">
|
||||||
|
<Trophy className="size-4 text-primary"/>
|
||||||
|
{t('PROFILE.GAME_ACHIEVEMENTS', {
|
||||||
|
unlocked: game.achievementsUnlocked,
|
||||||
|
total: game.achievementsTotal,
|
||||||
|
percent: game.completionPercent.toLocaleString(i18n.language),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,15 +33,17 @@ function ArtRow({id, label, appid, onRemove, disabled}) {
|
|||||||
* page itself.
|
* page itself.
|
||||||
*
|
*
|
||||||
* @param disabled the profile is private, or a save runs
|
* @param disabled the profile is private, or a save runs
|
||||||
|
* @param privateProfile explains why the controls are disabled
|
||||||
* @param onChange gets the changed fields only
|
* @param onChange gets the changed fields only
|
||||||
*/
|
*/
|
||||||
export default function ProfileStyleSettings({style, disabled, onChange}) {
|
export default function ProfileStyleSettings({style, disabled, privateProfile, onChange}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STYLE')}</span>
|
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STYLE')}</span>
|
||||||
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_STYLE_HINT')}</p>
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_STYLE_HINT')}</p>
|
||||||
|
{privateProfile && <p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_STYLE_PRIVATE')}</p>}
|
||||||
</div>
|
</div>
|
||||||
<AccentPicker id="profile-accent" value={style.accent} disabled={disabled}
|
<AccentPicker id="profile-accent" value={style.accent} disabled={disabled}
|
||||||
onChange={(accent) => onChange({accent})}/>
|
onChange={(accent) => onChange({accent})}/>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import ProfileStyleSettings from './ProfileStyleSettings.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const style = {accent: 'violet', corners: 'default', bannerAppid: null, backgroundAppid: null};
|
||||||
|
|
||||||
|
describe('ProfileStyleSettings', () => {
|
||||||
|
it('aPrivateProfile_SaysWhyTheControlsAreDisabled', () => {
|
||||||
|
render(<ProfileStyleSettings style={style} disabled privateProfile onChange={vi.fn()}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('SETTINGS.PROFILE_STYLE_PRIVATE')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aPublicProfile_ShowsNoExplanation', () => {
|
||||||
|
render(<ProfileStyleSettings style={style} disabled={false} privateProfile={false} onChange={vi.fn()}/>);
|
||||||
|
|
||||||
|
expect(screen.queryByText('SETTINGS.PROFILE_STYLE_PRIVATE')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Gamepad, Link2, Loader2, Unlink} from 'lucide-react';
|
import {Gamepad, Link2, Loader2} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
|
import UnlinkDialog from './UnlinkDialog.jsx';
|
||||||
import usePsnAccount from '../hooks/usePsnAccount.js';
|
import usePsnAccount from '../hooks/usePsnAccount.js';
|
||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js';
|
import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js';
|
||||||
@@ -102,9 +103,9 @@ export default function PsnAccountCard() {
|
|||||||
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
|
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
|
<UnlinkDialog label={t('SETTINGS.PSN.UNLINK')} title={t('SETTINGS.PSN.UNLINK_TITLE')}
|
||||||
<Unlink/>{t('SETTINGS.PSN.UNLINK')}
|
description={t('SETTINGS.PSN.UNLINK_EXPLAIN')}
|
||||||
</Button>
|
confirmLabel={t('SETTINGS.PSN.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -118,13 +118,28 @@ describe('PsnAccountCard', () => {
|
|||||||
expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull();
|
expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unlinks and shows the link form again', async () => {
|
it('asks before it unlinks and sends nothing on cancel', async () => {
|
||||||
|
renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(5)))});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('SETTINGS.PSN.UNLINK_EXPLAIN')).toBeTruthy();
|
||||||
|
expect(calls('DELETE')).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('SETTINGS.PSN.UNLINK_EXPLAIN')).toBeNull());
|
||||||
|
expect(calls('DELETE')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlinks after the confirmation and shows the link form again', async () => {
|
||||||
renderCard({
|
renderCard({
|
||||||
'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))),
|
'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))),
|
||||||
'DELETE /api/library/psn-account': noBody,
|
'DELETE /api/library/psn-account': noBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.PSN.UNLINK/}));
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK'}));
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK_CONFIRM'}));
|
||||||
|
|
||||||
expect(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
|
expect(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
|
||||||
expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED');
|
expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED');
|
||||||
|
|||||||
@@ -3,18 +3,26 @@ import {useTranslation} from 'react-i18next';
|
|||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
import FitText from './FitText.jsx';
|
import FitText from './FitText.jsx';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import RatingStars from './RatingStars.jsx';
|
import RatingStars from './RatingStars.jsx';
|
||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
|
// Only START and FINISHED earn points.
|
||||||
|
const pointsText = (question, expectedPoints, t) => {
|
||||||
|
if (question === 'START') return t('QUESTIONS.START.POINTS');
|
||||||
|
if (question !== 'FINISHED') return null;
|
||||||
|
return expectedPoints > 0 ? t('QUESTIONS.FINISHED.POINTS', {count: expectedPoints}) : t('QUESTIONS.POINTS_PENDING');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One open question of one game. The page decides what an answer does.
|
* One open question of one game. The page decides what an answer does.
|
||||||
*
|
*
|
||||||
* @param item a result of `GET /api/library/questions`
|
* @param item a result of `GET /api/library/questions`
|
||||||
* @param busy true while an answer is on its way
|
* @param busy true while an answer is on its way
|
||||||
* @param expectedPoints the points a finish would pay (`GET /api/rewards/expected`), absent or 0 shows nothing
|
* @param expectedPoints the points a finish would pay (`GET /api/rewards/expected`), absent or 0 shows "points by time to beat"
|
||||||
* @param onAnswer `({yes, rating?})`; a yes to the rating question comes with the stars
|
* @param onAnswer `({yes, rating?})`; a yes to the rating question comes with the stars
|
||||||
*/
|
*/
|
||||||
export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
|
export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
|
||||||
@@ -27,6 +35,7 @@ export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
|
|||||||
date: lastActivity ? formatDate(lastActivity, i18n.language) : '',
|
date: lastActivity ? formatDate(lastActivity, i18n.language) : '',
|
||||||
});
|
});
|
||||||
const askId = `question-${appid}`;
|
const askId = `question-${appid}`;
|
||||||
|
const points = pointsText(question, expectedPoints, t);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex-row items-start gap-3 p-4">
|
<Card className="flex-row items-start gap-3 p-4">
|
||||||
@@ -39,9 +48,9 @@ export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
|
|||||||
</Link>
|
</Link>
|
||||||
<p id={askId} className="text-base font-semibold">{t(`QUESTIONS.${question}.ASK`)}</p>
|
<p id={askId} className="text-base font-semibold">{t(`QUESTIONS.${question}.ASK`)}</p>
|
||||||
<p className="text-sm text-muted-foreground">{reason}</p>
|
<p className="text-sm text-muted-foreground">{reason}</p>
|
||||||
{question === 'FINISHED' && expectedPoints > 0 && (
|
<p className={cn('text-sm font-medium', points ? 'text-primary' : 'text-muted-foreground')}>
|
||||||
<p className="text-sm font-medium text-primary">{t('QUESTIONS.FINISHED.POINTS', {count: expectedPoints})}</p>
|
{points ?? t('QUESTIONS.NO_POINTS')}
|
||||||
)}
|
</p>
|
||||||
{question === 'RATE' ? (
|
{question === 'RATE' ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<RatingStars value={0} labelledBy={askId} disabled={busy}
|
<RatingStars value={0} labelledBy={askId} disabled={busy}
|
||||||
|
|||||||
@@ -48,4 +48,30 @@ describe('QuestionCard', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('link', {name: 'Hades'}).getAttribute('href')).toBe('/library/10');
|
expect(screen.getByRole('link', {name: 'Hades'}).getAttribute('href')).toBe('/library/10');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('points line', () => {
|
||||||
|
const renderWithPoints = (props, expectedPoints) => render(
|
||||||
|
<MemoryRouter><QuestionCard item={item(props)} busy={false} expectedPoints={expectedPoints} onAnswer={vi.fn()}/></MemoryRouter>);
|
||||||
|
|
||||||
|
it('start_ShowsThePointsOfAStart', () => {
|
||||||
|
renderWithPoints({question: 'START'});
|
||||||
|
|
||||||
|
expect(screen.getByText('QUESTIONS.START.POINTS')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finished_ShowsTheExpectedPointsOrThePendingText', () => {
|
||||||
|
const {unmount} = renderWithPoints({question: 'FINISHED'}, 40);
|
||||||
|
expect(screen.getByText('QUESTIONS.FINISHED.POINTS:40')).toBeTruthy();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderWithPoints({question: 'FINISHED'}, 0);
|
||||||
|
expect(screen.getByText('QUESTIONS.POINTS_PENDING')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['DROPPED', 'RATE'])('%s_SaysThereAreNoPoints', (question) => {
|
||||||
|
renderWithPoints({question});
|
||||||
|
|
||||||
|
expect(screen.getByText('QUESTIONS.NO_POINTS')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,9 +8,19 @@ import {cn} from '@/lib/utils';
|
|||||||
*
|
*
|
||||||
* @param value 0 for no rating
|
* @param value 0 for no rating
|
||||||
* @param labelledBy id of the element that names the group
|
* @param labelledBy id of the element that names the group
|
||||||
|
* @param readOnly shows the stars as one image, without buttons
|
||||||
*/
|
*/
|
||||||
export default function RatingStars({value, onChange, labelledBy, disabled}) {
|
export default function RatingStars({value, onChange, labelledBy, disabled, readOnly}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<span role="img" aria-label={t('BACKLOG.RATING_STARS', {count: value})} className="inline-flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Star key={n} className={cn('size-3.5', n <= value ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40')}/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div role="group" aria-labelledby={labelledBy} className="flex h-9 items-center gap-1">
|
<div role="group" aria-labelledby={labelledBy} className="flex h-9 items-center gap-1">
|
||||||
{[1, 2, 3, 4, 5].map((n) => (
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
@@ -21,7 +31,7 @@ export default function RatingStars({value, onChange, labelledBy, disabled}) {
|
|||||||
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
|
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
|
||||||
aria-pressed={value === n}
|
aria-pressed={value === n}
|
||||||
onClick={() => onChange(value === n ? 0 : n)}
|
onClick={() => onChange(value === n ? 0 : n)}
|
||||||
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
className="rounded-sm p-0.5 pointer-coarse:size-11 pointer-coarse:p-2.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
>
|
>
|
||||||
<Star className={cn('size-6 transition-colors',
|
<Star className={cn('size-6 transition-colors',
|
||||||
n <= value ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
|
n <= value ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Loader2} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import {fetchRemovedGames, restoreGame} from '../utils/manualGames.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The games the user removed, one Restore button each: the "Removed" filter of the library. A list that does not load
|
||||||
|
* looks empty, like a list without games.
|
||||||
|
*/
|
||||||
|
export default function RemovedGamesList() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [games, setGames] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
fetchRemovedGames().then(({ok, data}) => {
|
||||||
|
if (active) setGames(ok ? data.results : []);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restore = async (appid) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await restoreGame(appid);
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (response.ok || response.status === 404) {
|
||||||
|
setGames((current) => current.filter((game) => game.appid !== appid));
|
||||||
|
if (response.ok) toast.success(t('REMOVED_GAMES.RESTORED'));
|
||||||
|
} else {
|
||||||
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('restore error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!games) return <Loader2 className="size-5 animate-spin text-muted-foreground"/>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('REMOVED_GAMES.HINT')}</p>
|
||||||
|
{games.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('LIBRARY.NO_REMOVED')}</p>
|
||||||
|
) : (
|
||||||
|
<Card className="py-0">
|
||||||
|
<ul className="divide-y">
|
||||||
|
{games.map((game) => (
|
||||||
|
<li key={game.appid} className="flex items-center gap-3 p-3">
|
||||||
|
<div className="w-16 shrink-0">
|
||||||
|
<GameCover appid={game.appid} imageUrl={game.imageUrl}/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{game.name ?? `App ${game.appid}`}</p>
|
||||||
|
{game.platform && <p className="text-sm text-muted-foreground">{game.platform}</p>}
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => restore(game.appid)}>
|
||||||
|
{t('REMOVED_GAMES.RESTORE')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
|
|||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import RemovedGamesSection from './RemovedGamesSection.jsx';
|
import RemovedGamesList from './RemovedGamesList.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
@@ -14,7 +14,7 @@ const LIST = 'GET /api/library/removed-games';
|
|||||||
|
|
||||||
const renderSection = (routes) => {
|
const renderSection = (routes) => {
|
||||||
fetchRoutes(routes);
|
fetchRoutes(routes);
|
||||||
return render(<MemoryRouter><RemovedGamesSection/></MemoryRouter>);
|
return render(<MemoryRouter><RemovedGamesList/></MemoryRouter>);
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -22,14 +22,14 @@ afterEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RemovedGamesSection', () => {
|
describe('RemovedGamesList', () => {
|
||||||
it('isClosedByDefaultAndListsTheGames', async () => {
|
it('listsTheGamesWithTheHint', async () => {
|
||||||
const {container} = renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]})});
|
renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]})});
|
||||||
|
|
||||||
expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:2')).toBeTruthy();
|
expect(await screen.findByText('Zelda')).toBeTruthy();
|
||||||
expect(container.querySelector('details').open).toBe(false);
|
|
||||||
expect(screen.getByText('Zelda')).toBeTruthy();
|
|
||||||
expect(screen.getByText('Mario')).toBeTruthy();
|
expect(screen.getByText('Mario')).toBeTruthy();
|
||||||
|
expect(screen.getByText('REMOVED_GAMES.HINT')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('LIBRARY.NO_REMOVED')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('showsNoLinkToTheGamePage', async () => {
|
it('showsNoLinkToTheGamePage', async () => {
|
||||||
@@ -66,17 +66,15 @@ describe('RemovedGamesSection', () => {
|
|||||||
expect(screen.getByText('Zelda')).toBeTruthy();
|
expect(screen.getByText('Zelda')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rendersNothingForAnEmptyList', async () => {
|
it('saysSoForAnEmptyList', async () => {
|
||||||
const {container} = renderSection({[LIST]: json(200, {results: []})});
|
renderSection({[LIST]: json(200, {results: []})});
|
||||||
|
|
||||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
expect(await screen.findByText('LIBRARY.NO_REMOVED')).toBeTruthy();
|
||||||
expect(container.innerHTML).toBe('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rendersNothingWhenTheListFails', async () => {
|
it('saysSoWhenTheListFails', async () => {
|
||||||
const {container} = renderSection({[LIST]: json(500, {})});
|
renderSection({[LIST]: json(500, {})});
|
||||||
|
|
||||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
expect(await screen.findByText('LIBRARY.NO_REMOVED')).toBeTruthy();
|
||||||
expect(container.innerHTML).toBe('');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import {useEffect, useState} from 'react';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
import {toast} from 'sonner';
|
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
|
||||||
import GameCover from './GameCover.jsx';
|
|
||||||
import {fetchRemovedGames, restoreGame} from '../utils/manualGames.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The games the user removed, in a closed `<details>`, one Restore button each. Shows nothing when there are none or
|
|
||||||
* the list does not load: it is a safety net, not a page.
|
|
||||||
*/
|
|
||||||
export default function RemovedGamesSection() {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [games, setGames] = useState([]);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
fetchRemovedGames().then(({ok, data}) => {
|
|
||||||
if (active && ok) setGames(data.results);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const restore = async (appid) => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await restoreGame(appid);
|
|
||||||
if (response.status === 401) return;
|
|
||||||
if (response.ok || response.status === 404) {
|
|
||||||
setGames((current) => current.filter((game) => game.appid !== appid));
|
|
||||||
if (response.ok) toast.success(t('REMOVED_GAMES.RESTORED'));
|
|
||||||
} else {
|
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('restore error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (games.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<details className="rounded-lg border p-4">
|
|
||||||
<summary className="cursor-pointer font-medium">{t('REMOVED_GAMES.TITLE_COUNT', {count: games.length})}</summary>
|
|
||||||
<p className="mt-3 text-sm text-muted-foreground">{t('REMOVED_GAMES.HINT')}</p>
|
|
||||||
<ul className="mt-3 space-y-3">
|
|
||||||
{games.map((game) => (
|
|
||||||
<li key={game.appid} className="flex items-center gap-3">
|
|
||||||
<div className="w-16 shrink-0">
|
|
||||||
<GameCover appid={game.appid} imageUrl={game.imageUrl}/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate font-medium">{game.name ?? `App ${game.appid}`}</p>
|
|
||||||
{game.platform && <p className="text-sm text-muted-foreground">{game.platform}</p>}
|
|
||||||
</div>
|
|
||||||
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => restore(game.appid)}>
|
|
||||||
{t('REMOVED_GAMES.RESTORE')}
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {useNavigate} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ArrowLeft, Plus} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {AddForm} from './AddGamePanel.jsx';
|
||||||
|
import FitText from './FitText.jsx';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import TimeToBeat from './TimeToBeat.jsx';
|
||||||
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {fetchPreview} from '../utils/catalog.js';
|
||||||
|
import {hitTitle} from '../utils/manualGames.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A search hit the user does not have: what the hit says, genres and time to beat when the catalog knows the game,
|
||||||
|
* and the add form behind an "Add" button.
|
||||||
|
*/
|
||||||
|
export default function SearchHitCard({hit, onBack}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {user} = useCurrentUserContext();
|
||||||
|
const favorite = user?.settings?.favoritePlatform ?? null;
|
||||||
|
const [preview, setPreview] = useState(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setPreview(null);
|
||||||
|
if (!hit.inCatalog) return undefined;
|
||||||
|
(async () => {
|
||||||
|
const {ok, data} = await fetchPreview(hit.appid);
|
||||||
|
if (!cancelled && ok && data) setPreview(data);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [hit.appid, hit.inCatalog]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="gap-4 p-5">
|
||||||
|
{adding ? (
|
||||||
|
<AddForm hit={hit} favorite={favorite} onBack={onBack} navigate={navigate}/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-start">
|
||||||
|
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-full md:w-72 md:shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<h1 className="@container text-2xl font-bold">
|
||||||
|
<FitText text={hitTitle(hit)}/>
|
||||||
|
</h1>
|
||||||
|
{hit.platforms.length > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{hit.platforms.join(' · ')}</p>
|
||||||
|
)}
|
||||||
|
{preview?.genres?.length > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{preview.genres.join(', ')}</p>
|
||||||
|
)}
|
||||||
|
{preview && <TimeToBeat time={preview.timeToBeat}/>}
|
||||||
|
<p className="text-sm text-muted-foreground">{t('CATALOG.NOT_IN_LIBRARY')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="ghost" onClick={onBack}><ArrowLeft/>{t('MANUAL_GAME.BACK_TO_SEARCH')}</Button>
|
||||||
|
<Button onClick={() => setAdding(true)}><Plus/>{t('CATALOG.ADD')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@ import {useEffect, useState} from 'react';
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import useReducedMotion from '../hooks/useReducedMotion.js';
|
||||||
import {useDrillTarget} from './stats/StatDrill.jsx';
|
import {useDrillTarget} from './stats/StatDrill.jsx';
|
||||||
|
|
||||||
function useCountUp(target, durationMs = 1600) {
|
function useCountUp(target, instant, durationMs = 1600) {
|
||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0;
|
const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0;
|
||||||
if (safeTarget === 0) {
|
if (instant || safeTarget === 0) {
|
||||||
setValue(0);
|
setValue(safeTarget);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let raf;
|
let raf;
|
||||||
@@ -22,7 +23,7 @@ function useCountUp(target, durationMs = 1600) {
|
|||||||
};
|
};
|
||||||
raf = requestAnimationFrame(step);
|
raf = requestAnimationFrame(step);
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [target, durationMs]);
|
}, [target, instant, durationMs]);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ function useCountUp(target, durationMs = 1600) {
|
|||||||
*/
|
*/
|
||||||
export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) {
|
export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) {
|
||||||
const {i18n} = useTranslation();
|
const {i18n} = useTranslation();
|
||||||
const animated = useCountUp(value);
|
const reduce = useReducedMotion();
|
||||||
|
const animated = useCountUp(value, reduce);
|
||||||
const target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick});
|
const target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick});
|
||||||
const card = (
|
const card = (
|
||||||
<Card className="gap-3 p-5">
|
<Card className="gap-3 p-5">
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import StatCard from './StatCard.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
delete document.documentElement.dataset.reduceMotion;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StatCard', () => {
|
||||||
|
it('shows the final number at once when the user wants reduced motion', () => {
|
||||||
|
document.documentElement.dataset.reduceMotion = 'true';
|
||||||
|
render(<StatCard label="Games" value={1234}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('1.234')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts up from 0 otherwise', () => {
|
||||||
|
render(<StatCard label="Games" value={1234}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('0')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ChevronDown} from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
import GameStatusBadge, {STATUS_META} from './GameStatusBadge.jsx';
|
||||||
|
import {GAME_STATUSES} from '../utils/limits.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status badge as a menu: choosing another status calls `onSelect(status)`; the current one does nothing.
|
||||||
|
*
|
||||||
|
* @param busy disables the trigger while a save runs
|
||||||
|
* @param compact see GameStatusBadge
|
||||||
|
*/
|
||||||
|
export default function StatusMenu({status, onSelect, busy, compact, className}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" disabled={busy}
|
||||||
|
aria-label={`${t('BACKLOG.STATUS_LABEL')}: ${t(`BACKLOG.STATUS.${status}`)}`}
|
||||||
|
className={cn('inline-flex items-center gap-1 rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 pointer-coarse:min-h-11', className)}>
|
||||||
|
<GameStatusBadge status={status} compact={compact}/>
|
||||||
|
<ChevronDown className="size-3 text-muted-foreground"/>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuRadioGroup value={status} onValueChange={(s) => s !== status && onSelect(s)}>
|
||||||
|
{GAME_STATUSES.map((s) => {
|
||||||
|
const {icon: Icon, text} = STATUS_META[s];
|
||||||
|
return (
|
||||||
|
<DropdownMenuRadioItem key={s} value={s}>
|
||||||
|
<Icon className={text}/>
|
||||||
|
{t(`BACKLOG.STATUS.${s}`)}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
|
import StatusMenu from './StatusMenu.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const open = () => fireEvent.keyDown(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}), {key: 'Enter'});
|
||||||
|
|
||||||
|
describe('StatusMenu', () => {
|
||||||
|
it('namesTheCurrentStatusAndListsAllFive', async () => {
|
||||||
|
render(<StatusMenu status="PLAYING" onSelect={vi.fn()}/>);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', {name: 'BACKLOG.STATUS_LABEL: BACKLOG.STATUS.PLAYING'})).toBeTruthy();
|
||||||
|
open();
|
||||||
|
|
||||||
|
const items = await screen.findAllByRole('menuitemradio');
|
||||||
|
expect(items).toHaveLength(5);
|
||||||
|
expect(screen.getByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}).getAttribute('aria-checked')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('callsOnSelectForAnotherStatus', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<StatusMenu status="PLAYING" onSelect={onSelect}/>);
|
||||||
|
open();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.FINISHED'}));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('FINISHED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doesNothingForTheCurrentStatus', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<StatusMenu status="PLAYING" onSelect={onSelect}/>);
|
||||||
|
open();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}));
|
||||||
|
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isDisabledWhileBusy', () => {
|
||||||
|
render(<StatusMenu status="PLAYING" onSelect={vi.fn()} busy/>);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2, Unlink} from 'lucide-react';
|
import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import UnlinkDialog from './UnlinkDialog.jsx';
|
||||||
import {markSession} from '../utils/auth.js';
|
import {markSession} from '../utils/auth.js';
|
||||||
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
|||||||
const response = await unlinkSteam();
|
const response = await unlinkSteam();
|
||||||
if (response.status === 401) return;
|
if (response.status === 401) return;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
toast.error(t('SETTINGS.STEAM_LINK.UNLINK_ERROR'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The new access cookie has no Steam ID; the new stamp makes the header load again.
|
// The new access cookie has no Steam ID; the new stamp makes the header load again.
|
||||||
@@ -79,10 +80,10 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
|||||||
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
|
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
|
||||||
</Button>
|
</Button>
|
||||||
{steamId && (
|
{steamId && (
|
||||||
<Button type="button" variant="outline" onClick={unlink} disabled={busy}
|
<UnlinkDialog label={t('SETTINGS.STEAM_LINK.UNLINK')} title={t('SETTINGS.STEAM_LINK.UNLINK_TITLE')}
|
||||||
className="h-auto max-w-full whitespace-normal py-2 text-left">
|
description={t('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')}
|
||||||
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')}
|
confirmLabel={t('SETTINGS.STEAM_LINK.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}
|
||||||
</Button>
|
className="h-auto max-w-full whitespace-normal py-2 text-left"/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {ArrowRight, Gamepad2} from 'lucide-react';
|
import {ArrowRight, Gamepad2} from 'lucide-react';
|
||||||
|
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import useReducedMotion from '../hooks/useReducedMotion.js';
|
||||||
|
|
||||||
const DURATION_MS = 1000;
|
const DURATION_MS = 1000;
|
||||||
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
|
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
|
||||||
|
|
||||||
const SteamGameCount = ({targetCount}) => {
|
const SteamGameCount = ({targetCount}) => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const reduce = useReducedMotion();
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (reduce) {
|
||||||
|
setCount(targetCount);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
let frame;
|
let frame;
|
||||||
let startTime;
|
let startTime;
|
||||||
const animate = (timestamp) => {
|
const animate = (timestamp) => {
|
||||||
@@ -25,7 +31,7 @@ const SteamGameCount = ({targetCount}) => {
|
|||||||
};
|
};
|
||||||
frame = requestAnimationFrame(animate);
|
frame = requestAnimationFrame(animate);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [targetCount]);
|
}, [targetCount, reduce]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative justify-between overflow-hidden p-6">
|
<Card className="relative justify-between overflow-hidden p-6">
|
||||||
@@ -37,7 +43,7 @@ const SteamGameCount = ({targetCount}) => {
|
|||||||
{targetCount > 0
|
{targetCount > 0
|
||||||
? <Link to="/library" className={`${COUNT_CLASS} hover:text-primary`}>{count}</Link>
|
? <Link to="/library" className={`${COUNT_CLASS} hover:text-primary`}>{count}</Link>
|
||||||
: <span className={COUNT_CLASS}>{count}</span>}
|
: <span className={COUNT_CLASS}>{count}</span>}
|
||||||
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
|
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.GAMES')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||||
{t('DASHBOARD.TO_LIBRARY')} <ArrowRight className="size-4"/>
|
{t('DASHBOARD.TO_LIBRARY')} <ArrowRight className="size-4"/>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import SteamGameCount from './SteamGameCount.jsx';
|
|||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
describe('SteamGameCount', () => {
|
describe('SteamGameCount', () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
delete document.documentElement.dataset.reduceMotion;
|
||||||
|
});
|
||||||
|
|
||||||
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
|
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
|
||||||
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
|
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
|
||||||
@@ -15,6 +18,14 @@ describe('SteamGameCount', () => {
|
|||||||
|
|
||||||
rerender(<MemoryRouter><SteamGameCount targetCount={5}/></MemoryRouter>);
|
rerender(<MemoryRouter><SteamGameCount targetCount={5}/></MemoryRouter>);
|
||||||
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
|
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
|
||||||
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy();
|
expect(screen.getByText('DASHBOARD.GAMES')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheFinalCountAtOnceUnderReducedMotion', () => {
|
||||||
|
document.documentElement.dataset.reduceMotion = 'true';
|
||||||
|
|
||||||
|
render(<MemoryRouter><SteamGameCount targetCount={123}/></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.getByText('123')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -12,14 +12,30 @@ const formatRelative = (iso, locale) => {
|
|||||||
return rtf.format(-Math.round(diffSec / 86400), 'day');
|
return rtf.format(-Math.round(diffSec / 86400), 'day');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}) {
|
export default function SyncStatusBadge({status, lastSyncedAt, isRunning, queued, stale}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
|
const durationHint = <span className="text-xs text-muted-foreground">{t('LIBRARY.SYNC_DURATION_HINT')}</span>;
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-primary">
|
<div className="flex flex-col gap-1">
|
||||||
<RefreshCw className="size-4 animate-spin"/>
|
<div className="flex items-center gap-2 text-sm text-primary">
|
||||||
<span>{t('LIBRARY.SYNCING')}</span>
|
<RefreshCw className="size-4 animate-spin"/>
|
||||||
|
<span>{t('LIBRARY.SYNCING')}</span>
|
||||||
|
</div>
|
||||||
|
{!lastSyncedAt && durationHint}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queued) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Clock className="size-4"/>
|
||||||
|
<span>{t('LIBRARY.SYNC_QUEUED')}</span>
|
||||||
|
</div>
|
||||||
|
{durationHint}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,7 +44,7 @@ export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Clock className="size-4"/>
|
<Clock className="size-4"/>
|
||||||
<span>{t('LIBRARY.SYNC_STALE')}</span>
|
<span>{t(status == null ? 'LIBRARY.SYNC_NOT_STARTED' : 'LIBRARY.SYNC_STALE')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import SyncStatusBadge from './SyncStatusBadge.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
describe('SyncStatusBadge', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('says the first sync is queued, with the duration hint', () => {
|
||||||
|
render(<SyncStatusBadge status={null} lastSyncedAt={null} isRunning={false} queued/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('LIBRARY.SYNC_QUEUED')).toBeTruthy();
|
||||||
|
expect(screen.getByText('LIBRARY.SYNC_DURATION_HINT')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the duration hint while the first sync runs', () => {
|
||||||
|
render(<SyncStatusBadge status="RUNNING" lastSyncedAt={null} isRunning/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('LIBRARY.SYNCING')).toBeTruthy();
|
||||||
|
expect(screen.getByText('LIBRARY.SYNC_DURATION_HINT')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no duration hint for a later sync', () => {
|
||||||
|
render(<SyncStatusBadge status="RUNNING" lastSyncedAt="2026-10-21T08:00:00Z" isRunning/>);
|
||||||
|
|
||||||
|
expect(screen.queryByText('LIBRARY.SYNC_DURATION_HINT')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the calm text when a run is still going after the poll limit', () => {
|
||||||
|
render(<SyncStatusBadge status="RUNNING" lastSyncedAt={null} isRunning={false} stale/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('LIBRARY.SYNC_STALE')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the first sync did not start when no status row ever came', () => {
|
||||||
|
render(<SyncStatusBadge status={null} lastSyncedAt={null} isRunning={false} stale/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('LIBRARY.SYNC_NOT_STARTED')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('LIBRARY.SYNC_STALE')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pill navigation. The caller decides which tab is active, so a tab can be a path or a `?tab=` value.
|
||||||
|
*
|
||||||
|
* @param label accessible name of the navigation
|
||||||
|
* @param tabs `[{to, labelKey, icon, active}]`
|
||||||
|
*/
|
||||||
|
export default function TabNav({label, tabs}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<nav aria-label={label} className="flex max-w-full overflow-x-auto rounded-lg bg-muted p-1 text-sm sm:inline-flex sm:overflow-visible">
|
||||||
|
{tabs.map(({to, labelKey, icon: Icon, active}) => (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
title={t(labelKey)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 font-medium transition-colors',
|
||||||
|
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0"/>
|
||||||
|
{/* Below sm only the active tab shows its label; the others keep it as their name. */}
|
||||||
|
<span className={cn(!active && 'sr-only sm:not-sr-only')}>{t(labelKey)}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Unlink} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Unlink" button that asks first. The description says what the unlink does and does not do.
|
||||||
|
*
|
||||||
|
* @param size size of the trigger button
|
||||||
|
* @param icon icon component of the trigger button
|
||||||
|
*/
|
||||||
|
export default function UnlinkDialog({label, title, description, confirmLabel, onConfirm, disabled, className, size, icon: Icon = Unlink}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" size={size} disabled={disabled} className={className}>
|
||||||
|
<Icon/>{label}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">{t('DIALOG.CANCEL')}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="destructive" onClick={onConfirm}>{confirmLabel}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import {useEffect, useState} from 'react';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
import {toast} from 'sonner';
|
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
|
||||||
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
|
||||||
import NewsEntry from './NewsEntry.jsx';
|
|
||||||
import {fetchNews, markNewsSeen} from '../utils/news.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The news entries the user has not seen, newest first. Only "Got it" marks them seen: closing the dialog any other
|
|
||||||
* way shows them again on the next dashboard visit.
|
|
||||||
*/
|
|
||||||
export default function WhatsNewDialog() {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [unseen, setUnseen] = useState([]);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// A failed load shows nothing, not an error.
|
|
||||||
fetchNews()
|
|
||||||
.then((response) => (response.ok ? response.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
const entries = (data?.results ?? []).filter((entry) => !entry.seen);
|
|
||||||
setUnseen(entries);
|
|
||||||
setOpen(entries.length > 0);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const gotIt = async () => {
|
|
||||||
setOpen(false);
|
|
||||||
try {
|
|
||||||
const response = await markNewsSeen();
|
|
||||||
if (!response.ok) toast.error(t('NEWS.SAVE_FAILED'));
|
|
||||||
} catch {
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{unseen.map((entry) => <NewsEntry key={entry.id} entry={entry}/>)}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={gotIt}>{t('NEWS.GOT_IT')}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
|
||||||
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
|
||||||
import {toast} from 'sonner';
|
|
||||||
import WhatsNewDialog from './WhatsNewDialog.jsx';
|
|
||||||
import {newsText, newsTitle} from '../utils/news.js';
|
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
|
||||||
vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
|
|
||||||
|
|
||||||
const NEW = {id: 2, titleEn: 'New feature', titleDe: 'Neue Funktion', textEn: 'English text', textDe: 'Deutscher Text',
|
|
||||||
publishedAt: '2026-10-01T10:00:00Z', seen: false};
|
|
||||||
const ENGLISH_ONLY = {...NEW, id: 3, titleEn: 'English only', titleDe: null, textEn: 'Only English', textDe: null};
|
|
||||||
const OLD = {...NEW, id: 1, titleEn: 'Old', titleDe: 'Alt', seen: true};
|
|
||||||
|
|
||||||
const newsRoutes = (list, seen = json(204)) => fetchRoutes({'GET /api/news': list, 'PUT /api/news/seen': seen});
|
|
||||||
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
|
|
||||||
|
|
||||||
const showNews = async (...entries) => {
|
|
||||||
newsRoutes(json(200, {results: entries}));
|
|
||||||
render(<WhatsNewDialog/>);
|
|
||||||
await act(async () => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('WhatsNewDialog', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows only the unseen entries, German when set and English otherwise', async () => {
|
|
||||||
await showNews(NEW, ENGLISH_ONLY, OLD);
|
|
||||||
|
|
||||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
|
||||||
expect(screen.getByText('Neue Funktion')).toBeTruthy();
|
|
||||||
expect(screen.getByText('Deutscher Text')).toBeTruthy();
|
|
||||||
expect(screen.getByText('English only')).toBeTruthy();
|
|
||||||
expect(screen.getByText('Only English')).toBeTruthy();
|
|
||||||
expect(screen.queryByText('Alt')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks the entries seen and closes on "Got it"', async () => {
|
|
||||||
await showNews(NEW);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('NEWS.GOT_IT'));
|
|
||||||
await act(async () => undefined);
|
|
||||||
|
|
||||||
expect(putCalls().map(([url]) => url)).toEqual([expect.stringContaining('/api/news/seen')]);
|
|
||||||
expect(screen.queryByRole('dialog')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes with X without marking anything seen', async () => {
|
|
||||||
await showNews(NEW);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'Close'}));
|
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).toBeNull();
|
|
||||||
expect(putCalls()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['all entries are seen', json(200, {results: [OLD]})],
|
|
||||||
['there are no entries', json(200, {results: []})],
|
|
||||||
['the load fails', json(500, {})],
|
|
||||||
])('shows nothing when %s', async (_, answer) => {
|
|
||||||
newsRoutes(answer);
|
|
||||||
render(<WhatsNewDialog/>);
|
|
||||||
await act(async () => undefined);
|
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).toBeNull();
|
|
||||||
expect(toast.error).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an error when the seen mark is not saved', async () => {
|
|
||||||
newsRoutes(json(200, {results: [NEW]}), json(500, {}));
|
|
||||||
render(<WhatsNewDialog/>);
|
|
||||||
await act(async () => undefined);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('NEWS.GOT_IT'));
|
|
||||||
await act(async () => undefined);
|
|
||||||
|
|
||||||
expect(toast.error).toHaveBeenCalledWith('NEWS.SAVE_FAILED');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('newsTitle / newsText', () => {
|
|
||||||
it('pick German only for a German UI and a set German field', () => {
|
|
||||||
expect(newsTitle(NEW, 'de-DE')).toBe('Neue Funktion');
|
|
||||||
expect(newsText(NEW, 'de')).toBe('Deutscher Text');
|
|
||||||
expect(newsTitle(NEW, 'en')).toBe('New feature');
|
|
||||||
expect(newsText(ENGLISH_ONLY, 'de')).toBe('Only English');
|
|
||||||
expect(newsTitle(ENGLISH_ONLY, undefined)).toBe('English only');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {ChartColumn, Dices, LayoutDashboard, Library, Users} from 'lucide-react';
|
||||||
|
|
||||||
|
// Search, the wishlist and the queue are reached from the library; the old following page is part of Community.
|
||||||
|
export const NAV_ITEMS = [
|
||||||
|
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||||
|
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog', '/search']},
|
||||||
|
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
||||||
|
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
||||||
|
{to: '/community', labelKey: 'NAV.COMMUNITY', Icon: Users, alsoActiveOn: ['/following']},
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = (to, pathname) => pathname === to || pathname.startsWith(`${to}/`);
|
||||||
|
|
||||||
|
export const isNavActive = (item, pathname) =>
|
||||||
|
matches(item.to, pathname) || (item.alsoActiveOn?.some((to) => matches(to, pathname)) ?? false);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
import {isNavActive, NAV_ITEMS} from './navItems.js';
|
||||||
|
|
||||||
|
const item = (to) => NAV_ITEMS.find((candidate) => candidate.to === to);
|
||||||
|
|
||||||
|
describe('isNavActive', () => {
|
||||||
|
it('theOwnPathAndItsChildrenAreActive', () => {
|
||||||
|
expect(isNavActive(item('/library'), '/library')).toBe(true);
|
||||||
|
expect(isNavActive(item('/library'), '/library/220')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thePagesOfTheLibraryKeepLibraryActive', () => {
|
||||||
|
for (const path of ['/wishlist', '/backlog', '/search']) expect(isNavActive(item('/library'), path)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('theOldFollowingUrlKeepsCommunityActive', () => {
|
||||||
|
expect(isNavActive(item('/community'), '/following')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aPathWithTheSamePrefixIsNotActive', () => {
|
||||||
|
expect(isNavActive(item('/library'), '/library-x')).toBe(false);
|
||||||
|
expect(isNavActive(item('/stats'), '/dashboard')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
import {formatDate} from '../../utils/format.js';
|
||||||
|
import {profileHref} from '../../utils/people.js';
|
||||||
|
|
||||||
|
// Newest activity first, people without any last; Core's name order stays within a tie.
|
||||||
|
const byActivity = (a, b) => (b.lastActivityAt ? Date.parse(b.lastActivityAt) : -Infinity)
|
||||||
|
- (a.lastActivityAt ? Date.parse(a.lastActivityAt) : -Infinity) || 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people the user follows, with the time of their newest feed message and an unfollow button.
|
||||||
|
*
|
||||||
|
* @param entries `following` of GET /api/follows
|
||||||
|
* @param limit shows only the first `limit` after the sort
|
||||||
|
* @param onUnfollow called with the follow row id
|
||||||
|
* @param compact tighter rows for a tile
|
||||||
|
*/
|
||||||
|
export default function FollowingList({entries, limit, onUnfollow, compact}) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWING')}</p>;
|
||||||
|
const shown = [...entries].sort(byActivity).slice(0, limit);
|
||||||
|
return (
|
||||||
|
<ul className={cn('divide-y', compact && 'text-sm')}>
|
||||||
|
{shown.map((entry) => (
|
||||||
|
<li key={entry.id} className={cn('flex items-center gap-3', compact ? 'py-2' : 'py-3')}>
|
||||||
|
<span aria-hidden="true"
|
||||||
|
className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 font-semibold uppercase text-primary">
|
||||||
|
{entry.displayName?.[0]}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link to={profileHref(entry.displayName)}
|
||||||
|
className="block truncate font-medium hover:text-primary hover:underline">{entry.displayName}</Link>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{entry.lastActivityAt
|
||||||
|
? t('FOLLOW.LAST_ACTIVITY', {date: formatDate(entry.lastActivityAt, i18n.language, {dateStyle: 'medium'})})
|
||||||
|
: t('FOLLOW.NO_ACTIVITY')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onUnfollow(entry.id)}>{t('FOLLOW.UNFOLLOW')}</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Loader2, Search} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import FitText from '../FitText.jsx';
|
||||||
|
import {searchPlayers} from '../../utils/follows.js';
|
||||||
|
import {profileHref} from '../../utils/people.js';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 400;
|
||||||
|
const MIN_LENGTH = 2;
|
||||||
|
const MAX_LENGTH = 100;
|
||||||
|
|
||||||
|
// idle | loading | done | limited | failed
|
||||||
|
function usePlayerSearch(query) {
|
||||||
|
const [state, setState] = useState('idle');
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed.length < MIN_LENGTH) {
|
||||||
|
setState('idle');
|
||||||
|
setResults([]);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setState('loading');
|
||||||
|
const {ok, status, data} = await searchPlayers(trimmed, controller.signal);
|
||||||
|
if (controller.signal.aborted || status === 401) return;
|
||||||
|
if (ok) {
|
||||||
|
setResults(data.results);
|
||||||
|
setState('done');
|
||||||
|
} else {
|
||||||
|
setState(status === 429 ? 'limited' : 'failed');
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return {state, results};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches public profiles by name; a hit links to the profile, where the user can follow.
|
||||||
|
*/
|
||||||
|
export default function PlayerSearch() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const {state, results} = usePlayerSearch(query);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||||
|
<Input type="search" value={query} maxLength={MAX_LENGTH} className="pl-8"
|
||||||
|
placeholder={t('COMMUNITY.SEARCH_PLACEHOLDER')} aria-label={t('COMMUNITY.SEARCH_PLACEHOLDER')}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('COMMUNITY.SEARCH_HINT')}</p>
|
||||||
|
{state === 'loading' && <Loader2 className="size-4 animate-spin text-muted-foreground"/>}
|
||||||
|
{state === 'limited' && <p className="text-sm text-muted-foreground">{t('COMMUNITY.SEARCH_LIMITED')}</p>}
|
||||||
|
{state === 'failed' && <p className="text-sm text-destructive">{t('COMMUNITY.SEARCH_FAILED')}</p>}
|
||||||
|
{state === 'done' && results.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('COMMUNITY.SEARCH_NONE')}</p>
|
||||||
|
)}
|
||||||
|
{state === 'done' && results.length > 0 && (
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{results.map(({displayName}) => (
|
||||||
|
<li key={displayName}>
|
||||||
|
<Link to={profileHref(displayName)}
|
||||||
|
className="@container block px-3 py-2 font-medium transition-colors hover:bg-muted/50 hover:text-primary">
|
||||||
|
<FitText text={displayName}/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, fireEvent, screen} from '@testing-library/react';
|
||||||
|
import PlayerSearch from './PlayerSearch.jsx';
|
||||||
|
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 400;
|
||||||
|
const searchCalls = (mock) => mock.mock.calls.filter(([url]) => url.includes('/api/follows/players'));
|
||||||
|
const type = (text) => fireEvent.change(screen.getByRole('searchbox'), {target: {value: text}});
|
||||||
|
const me = {'GET /api/users/me': json(200, {settings: {}})};
|
||||||
|
const settle = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||||
|
|
||||||
|
describe('PlayerSearch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typingTwoLetters_SearchesOnceAfterTheDebounceAndLinksTheHits', async () => {
|
||||||
|
const mock = fetchRoutes({...me, 'GET /api/follows/players': json(200, {results: [{displayName: 'Lobo'}, {displayName: 'Zolo'}]})});
|
||||||
|
renderWithProviders(<PlayerSearch/>);
|
||||||
|
|
||||||
|
type('l');
|
||||||
|
type('lo');
|
||||||
|
await settle(DEBOUNCE_MS - 1);
|
||||||
|
expect(searchCalls(mock)).toHaveLength(0);
|
||||||
|
await settle(1);
|
||||||
|
|
||||||
|
expect(searchCalls(mock)).toHaveLength(1);
|
||||||
|
expect(searchCalls(mock)[0][0]).toContain('q=lo');
|
||||||
|
expect(screen.getByRole('link', {name: 'Lobo'}).getAttribute('href')).toBe('/u/Lobo');
|
||||||
|
expect(screen.getByRole('link', {name: 'Zolo'})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('oneLetter_SendsNoRequest', async () => {
|
||||||
|
const mock = fetchRoutes(me);
|
||||||
|
renderWithProviders(<PlayerSearch/>);
|
||||||
|
|
||||||
|
type('l');
|
||||||
|
await settle(DEBOUNCE_MS * 2);
|
||||||
|
|
||||||
|
expect(searchCalls(mock)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('noHit_SaysSo', async () => {
|
||||||
|
fetchRoutes({...me, 'GET /api/follows/players': json(200, {results: []})});
|
||||||
|
renderWithProviders(<PlayerSearch/>);
|
||||||
|
|
||||||
|
type('xx');
|
||||||
|
await settle(DEBOUNCE_MS);
|
||||||
|
|
||||||
|
expect(screen.getByText('COMMUNITY.SEARCH_NONE')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answer429_ShowsTheLimitText', async () => {
|
||||||
|
fetchRoutes({...me, 'GET /api/follows/players': json(429, {code: 'TOO_MANY_SEARCHES'})});
|
||||||
|
renderWithProviders(<PlayerSearch/>);
|
||||||
|
|
||||||
|
type('lo');
|
||||||
|
await settle(DEBOUNCE_MS);
|
||||||
|
|
||||||
|
expect(screen.getByText('COMMUNITY.SEARCH_LIMITED')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answer500_ShowsTheFailedText', async () => {
|
||||||
|
fetchRoutes({...me, 'GET /api/follows/players': json(500, {})});
|
||||||
|
renderWithProviders(<PlayerSearch/>);
|
||||||
|
|
||||||
|
type('lo');
|
||||||
|
await settle(DEBOUNCE_MS);
|
||||||
|
|
||||||
|
expect(screen.getByText('COMMUNITY.SEARCH_FAILED')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {StatCount} from './StatDrill.jsx';
|
import {StatCount} from './StatDrill.jsx';
|
||||||
import {formatDay} from '../../utils/format.js';
|
import {formatDay} from '../../utils/format.js';
|
||||||
import {trendPoints} from '../../utils/stats.js';
|
import {trendNet, trendPoints, trendRange} from '../../utils/stats.js';
|
||||||
import {trendBars} from '../../utils/statCharts.js';
|
import {trendBars} from '../../utils/statCharts.js';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
const WIDTH = 300;
|
const WIDTH = 300;
|
||||||
const HEIGHT = 60;
|
const HEIGHT = 60;
|
||||||
|
|
||||||
|
// A bigger pile is bad news, a smaller one good news.
|
||||||
|
const DIRECTIONS = {
|
||||||
|
up: {text: 'text-rose-600 dark:text-rose-400', stroke: 'stroke-rose-500', fill: 'fill-rose-500'},
|
||||||
|
down: {text: 'text-emerald-600 dark:text-emerald-400', stroke: 'stroke-emerald-500', fill: 'fill-emerald-500'},
|
||||||
|
flat: {text: 'text-muted-foreground', stroke: 'stroke-muted-foreground', fill: 'fill-muted-foreground'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const signed = (net) => (net > 0 ? `+${net}` : net < 0 ? `−${-net}` : '±0');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pile size over the last 90 days and the moves of this month.
|
* The pile size over the last 90 days and the moves of this month.
|
||||||
*
|
*
|
||||||
@@ -16,8 +26,18 @@ const HEIGHT = 60;
|
|||||||
export default function PileTrend({trend, chart = 'LINE'}) {
|
export default function PileTrend({trend, chart = 'LINE'}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {days, thisMonth} = trend;
|
const {days, thisMonth} = trend;
|
||||||
|
const net = trendNet(days);
|
||||||
|
const direction = DIRECTIONS[net > 0 ? 'up' : net < 0 ? 'down' : 'flat'];
|
||||||
|
const {min, max} = trendRange(days);
|
||||||
|
// The chart starts at 0; the min label shows only when it does not touch the max label.
|
||||||
|
const showMin = max > 0 && min / max < 0.8;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{days.length > 1 && (
|
||||||
|
<p data-testid="pile-net" className={cn('text-2xl font-bold tabular-nums', direction.text)}>
|
||||||
|
{t('STATS.TREND.NET', {net: signed(net), count: Math.abs(net)})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span>{t('STATS.TREND.THIS_MONTH')}</span>{' '}
|
<span>{t('STATS.TREND.THIS_MONTH')}</span>{' '}
|
||||||
<StatCount params={thisMonth.added > 0 ? {key: 'TREND_ADDED'} : null} title={t('STATS.DRILL.TREND_ADDED')}>
|
<StatCount params={thisMonth.added > 0 ? {key: 'TREND_ADDED'} : null} title={t('STATS.DRILL.TREND_ADDED')}>
|
||||||
@@ -30,18 +50,27 @@ export default function PileTrend({trend, chart = 'LINE'}) {
|
|||||||
</p>
|
</p>
|
||||||
{days.length > 1 ? (
|
{days.length > 1 ? (
|
||||||
<div>
|
<div>
|
||||||
<StatCount params={{key: 'TREND_MOVES'}} title={t('STATS.DRILL.TREND_MOVES')}
|
<div className="flex gap-2">
|
||||||
label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline">
|
<div aria-hidden="true" className="relative h-16 shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
|
<span className="absolute right-0 top-0 leading-none">{max}</span>
|
||||||
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
{showMin && (
|
||||||
{chart === 'BARS' ? trendBars(days, WIDTH, HEIGHT).map((bar) => (
|
<span className="absolute right-0 translate-y-1/2 leading-none"
|
||||||
<rect key={bar.x} x={bar.x} y={bar.y} width={bar.w} height={bar.h} className="fill-rose-500"/>
|
style={{bottom: `${(min / max) * 100}%`}}>{min}</span>
|
||||||
)) : (
|
|
||||||
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
|
||||||
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
|
||||||
)}
|
)}
|
||||||
</svg>
|
</div>
|
||||||
</StatCount>
|
<StatCount params={{key: 'TREND_MOVES'}} title={t('STATS.DRILL.TREND_MOVES')}
|
||||||
|
label={t('STATS.DRILL.TREND_MOVES')} className="flex min-w-0 flex-1 no-underline">
|
||||||
|
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
|
||||||
|
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
||||||
|
{chart === 'BARS' ? trendBars(days, WIDTH, HEIGHT).map((bar) => (
|
||||||
|
<rect key={bar.x} x={bar.x} y={bar.y} width={bar.w} height={bar.h} className={direction.fill}/>
|
||||||
|
)) : (
|
||||||
|
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
||||||
|
strokeWidth={2} vectorEffect="non-scaling-stroke" className={direction.stroke}/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</StatCount>
|
||||||
|
</div>
|
||||||
<table className="sr-only">
|
<table className="sr-only">
|
||||||
<tbody>
|
<tbody>
|
||||||
{days.map((d) => (
|
{days.map((d) => (
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, screen} from '@testing-library/react';
|
||||||
|
import PileTrend from './PileTrend.jsx';
|
||||||
|
import {renderWithProviders} from '../../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const day = (date, backlogGames) => ({day: date, backlogGames, neverPlayed: 0, backlogMinutes: 0});
|
||||||
|
const trendOf = (...sizes) => ({
|
||||||
|
days: sizes.map((size, index) => day(`2026-09-0${index + 1}`, size)),
|
||||||
|
thisMonth: {added: 1, done: 1},
|
||||||
|
});
|
||||||
|
const renderTrend = (trend, chart) => renderWithProviders(<PileTrend trend={trend} chart={chart}/>);
|
||||||
|
|
||||||
|
describe('PileTrend', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('aGrowingPile_IsRedAndAShrinkingPileIsGreen', () => {
|
||||||
|
renderTrend(trendOf(5, 8));
|
||||||
|
expect(screen.getByTestId('pile-net').className).toContain('text-rose-600');
|
||||||
|
expect(screen.getByTestId('pile-trend').getAttribute('class')).toContain('stroke-rose-500');
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
renderTrend(trendOf(8, 5));
|
||||||
|
expect(screen.getByTestId('pile-net').className).toContain('text-emerald-600');
|
||||||
|
expect(screen.getByTestId('pile-trend').getAttribute('class')).toContain('stroke-emerald-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anUnchangedPile_IsMuted', () => {
|
||||||
|
renderTrend(trendOf(5, 7, 5));
|
||||||
|
expect(screen.getByTestId('pile-net').className).toContain('text-muted-foreground');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bars_TakeTheDirectionColor', () => {
|
||||||
|
const {container} = renderTrend(trendOf(5, 8), 'BARS');
|
||||||
|
expect(container.querySelector('rect').getAttribute('class')).toContain('fill-rose-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheBiggestAndTheSmallestPile', () => {
|
||||||
|
const {container} = renderTrend(trendOf(10, 4, 6));
|
||||||
|
const labels = [...container.querySelectorAll('[aria-hidden="true"] span')].map((span) => span.textContent);
|
||||||
|
expect(labels).toEqual(['10', '4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('oneDay_HasNoHeadline', () => {
|
||||||
|
renderTrend(trendOf(5));
|
||||||
|
expect(screen.queryByTestId('pile-net')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,13 +63,14 @@ function SliceRow({slice, drillKey, label, linkOf, hrefOf, valueText, color}) {
|
|||||||
* @param linkOf library URL of a slice name; the own views link there instead of opening the dialog
|
* @param linkOf library URL of a slice name; the own views link there instead of opening the dialog
|
||||||
* @param hrefOf `(slice) => url|null`: a slice links there instead of using `linkOf` or the dialog; never "other" or 0
|
* @param hrefOf `(slice) => url|null`: a slice links there instead of using `linkOf` or the dialog; never "other" or 0
|
||||||
* @param valueText `(slice) => string`: the text at the right of a legend row
|
* @param valueText `(slice) => string`: the text at the right of a legend row
|
||||||
|
* @param colorOf `(slice) => color`: the colors of the slices with games, instead of the palette
|
||||||
* @param center shown in the hole of a donut
|
* @param center shown in the hole of a donut
|
||||||
* @param note a line under the legend
|
* @param note a line under the legend
|
||||||
*
|
*
|
||||||
* A slice without games has a legend row only.
|
* A slice without games has a legend row only.
|
||||||
*/
|
*/
|
||||||
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, center, note}) {
|
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, colorOf, center, note}) {
|
||||||
const colors = sliceColors(slices);
|
const colors = colorOf ? slices.map((slice) => (slice.value > 0 ? colorOf(slice) : null)) : sliceColors(slices);
|
||||||
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
|
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
|
||||||
return (
|
return (
|
||||||
<div className="@container space-y-2">
|
<div className="@container space-y-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {STATUS_META} from '../GameStatusBadge.jsx';
|
||||||
import SliceChart from './SliceChart.jsx';
|
import SliceChart from './SliceChart.jsx';
|
||||||
import {StatCount} from './StatDrill.jsx';
|
import {StatCount} from './StatDrill.jsx';
|
||||||
import {libraryLink} from '../../utils/statGames.js';
|
import {libraryLink} from '../../utils/statGames.js';
|
||||||
@@ -27,6 +28,7 @@ export default function StatusDonut({byStatus, totalParams = {key: 'TOTAL'}}) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<SliceChart slices={slices} donut drillKey="STATUS" label={(s) => t(`BACKLOG.STATUS.${s}`)}
|
<SliceChart slices={slices} donut drillKey="STATUS" label={(s) => t(`BACKLOG.STATUS.${s}`)}
|
||||||
linkOf={libraryLink} center={center}/>
|
linkOf={libraryLink} center={center}
|
||||||
|
colorOf={(slice) => STATUS_META[slice.name].color}/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -26,6 +27,7 @@ function DialogContent({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Portal>
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
@@ -43,7 +45,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t('DIALOG.CLOSE')}</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Portal>
|
</DialogPrimitive.Portal>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {useCallback} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
/** Copies a profile link. When the browser refuses, the error toast shows the link to copy by hand. */
|
||||||
|
export default function useCopyLink() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return useCallback(async (link) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
toast.success(t('SETTINGS.PUBLIC_PROFILE_COPIED'));
|
||||||
|
} catch {
|
||||||
|
toast.error(link);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import {fetchFollows, removeFollow} from '../utils/follows.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The follows answer (`following` and `followers`), and ending a follow with a reload. `follows` and `following` are null until loaded.
|
||||||
|
*/
|
||||||
|
export default function useFollowing() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [follows, setFollows] = useState(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const {ok, data} = await fetchFollows();
|
||||||
|
setFailed(!ok);
|
||||||
|
if (ok) setFollows(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const unfollow = async (id) => {
|
||||||
|
const {ok, status} = await removeFollow(id);
|
||||||
|
if (!ok && status !== 401) toast.error(t('FOLLOW.ERROR'));
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {follows, following: follows?.following ?? null, failed, unfollow};
|
||||||
|
}
|
||||||
@@ -3,12 +3,16 @@ import {apiFetch} from '../utils/api.js';
|
|||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
const MAX_POLL_DURATION_MS = 120_000;
|
const SLOW_POLL_INTERVAL_MS = 15_000;
|
||||||
|
const MAX_POLL_DURATION_MS = 600_000;
|
||||||
const INITIAL_SYNC_GRACE_MS = 15_000;
|
const INITIAL_SYNC_GRACE_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends.
|
* Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends.
|
||||||
* A run that still runs after 2 minutes is shown as `stale` and polling stops. `watch()` polls again after the
|
* While nothing runs yet (first sync queued, or a started sync not seen), it polls every 3 s for 15 s, then every
|
||||||
|
* 15 s, for 10 minutes in all; a run that is going polls every 15 s after the first 15 s, also up to 10 minutes. A run
|
||||||
|
* that still runs, or a first sync still without a row, after 10 minutes is shown as `stale` and polling stops.
|
||||||
|
* `queued` is true while a first sync is expected but Core has no status row yet. `watch()` polls again after the
|
||||||
* user started a sync: that run counts as ended when `lastSyncedAt` changes, also when no poll saw it running.
|
* user started a sync: that run counts as ended when `lastSyncedAt` changes, also when no poll saw it running.
|
||||||
*
|
*
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
@@ -16,8 +20,8 @@ const INITIAL_SYNC_GRACE_MS = 15_000;
|
|||||||
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
||||||
* gap between async sync dispatch and first DB write)
|
* gap between async sync dispatch and first DB write)
|
||||||
* @param {() => void} opts.onComplete - called once per run that ends
|
* @param {() => void} opts.onComplete - called once per run that ends
|
||||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, stale: boolean, error: string|null,
|
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, queued: boolean, stale: boolean,
|
||||||
* watch: () => void}}
|
* error: string|null, watch: () => void}}
|
||||||
*/
|
*/
|
||||||
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
|
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
|
||||||
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false});
|
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false});
|
||||||
@@ -68,7 +72,8 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
lastSyncedAtRef.current = lastSyncedAt;
|
lastSyncedAtRef.current = lastSyncedAt;
|
||||||
const isRunning = status === 'RUNNING';
|
const isRunning = status === 'RUNNING';
|
||||||
const elapsed = Date.now() - startedAt;
|
const elapsed = Date.now() - startedAt;
|
||||||
const stale = isRunning && elapsed >= MAX_POLL_DURATION_MS;
|
const waitingForInitial = expectInitialSync && status == null;
|
||||||
|
const stale = (isRunning || waitingForInitial) && elapsed >= MAX_POLL_DURATION_MS;
|
||||||
setState({status, lastSyncedAt, error: null, stale});
|
setState({status, lastSyncedAt, error: null, stale});
|
||||||
|
|
||||||
if (since === undefined) since = lastSyncedAt;
|
if (since === undefined) since = lastSyncedAt;
|
||||||
@@ -80,10 +85,9 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
}
|
}
|
||||||
wasRunning = isRunning;
|
wasRunning = isRunning;
|
||||||
|
|
||||||
const waitingForInitial = expectInitialSync && status == null;
|
const waitingForStart = (watching || waitingForInitial) && elapsed < MAX_POLL_DURATION_MS;
|
||||||
const waitingForStart = (watching || waitingForInitial) && elapsed < INITIAL_SYNC_GRACE_MS;
|
|
||||||
if ((isRunning && !stale) || waitingForStart) {
|
if ((isRunning && !stale) || waitingForStart) {
|
||||||
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
|
timer = setTimeout(fetchOnce, elapsed < INITIAL_SYNC_GRACE_MS ? POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -104,6 +108,7 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
status: state.status,
|
status: state.status,
|
||||||
lastSyncedAt: state.lastSyncedAt,
|
lastSyncedAt: state.lastSyncedAt,
|
||||||
isRunning: state.status === 'RUNNING' && !state.stale,
|
isRunning: state.status === 'RUNNING' && !state.stale,
|
||||||
|
queued: enabled && expectInitialSync && state.status == null && state.error == null && !state.stale,
|
||||||
stale: state.stale,
|
stale: state.stale,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
watch,
|
watch,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('useLibrarySync', () => {
|
|||||||
await act(() => vi.advanceTimersByTimeAsync(0));
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
expect(result.current.isRunning).toBe(true);
|
expect(result.current.isRunning).toBe(true);
|
||||||
|
|
||||||
await act(() => vi.advanceTimersByTimeAsync(125_000));
|
await act(() => vi.advanceTimersByTimeAsync(605_000));
|
||||||
|
|
||||||
expect(result.current.isRunning).toBe(false);
|
expect(result.current.isRunning).toBe(false);
|
||||||
expect(result.current.stale).toBe(true);
|
expect(result.current.stale).toBe(true);
|
||||||
@@ -33,6 +33,70 @@ describe('useLibrarySync', () => {
|
|||||||
expect(fetch.mock.calls.length).toBe(calls);
|
expect(fetch.mock.calls.length).toBe(calls);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('isNotQueuedWhenDisabled', async () => {
|
||||||
|
fetchRoutes({});
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: false, expectInitialSync: true, onComplete: () => {}}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
expect(result.current.queued).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is queued while a first sync is expected and Core has no status row', async () => {
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': status(null, null)});
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: true, expectInitialSync: true, onComplete: () => {}}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
expect(result.current.queued).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not queued without expectInitialSync, or once a row exists', async () => {
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': status(null, null)});
|
||||||
|
const first = renderHook(() => useLibrarySync({enabled: true, onComplete: () => {}}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
expect(first.result.current.queued).toBe(false);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': status('RUNNING', null)});
|
||||||
|
const second = renderHook(() => useLibrarySync({enabled: true, expectInitialSync: true, onComplete: () => {}}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
expect(second.result.current.queued).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls every 3 s for 15 s while queued, then every 15 s, and stops after 10 minutes', async () => {
|
||||||
|
const fetch = fetchRoutes({'GET /api/library/sync-status': status(null, null)});
|
||||||
|
renderHook(() => useLibrarySync({enabled: true, expectInitialSync: true, onComplete: () => {}}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
expect(fetch.mock.calls.length).toBe(1);
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(15_000));
|
||||||
|
expect(fetch.mock.calls.length).toBe(6);
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(60_000));
|
||||||
|
expect(fetch.mock.calls.length).toBe(10);
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(600_000));
|
||||||
|
const calls = fetch.mock.calls.length;
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(60_000));
|
||||||
|
expect(fetch.mock.calls.length).toBe(calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still sees a first sync that starts after one minute', async () => {
|
||||||
|
let answer = status(null, null);
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: true, expectInitialSync: true, onComplete}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(60_000));
|
||||||
|
expect(result.current.isRunning).toBe(false);
|
||||||
|
|
||||||
|
answer = status('RUNNING', null);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(15_000));
|
||||||
|
expect(result.current.isRunning).toBe(true);
|
||||||
|
|
||||||
|
answer = status('OK', '2026-10-21T08:00:00Z');
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(15_000));
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('after watch() reports the end of a sync once, also when it never saw it running', async () => {
|
it('after watch() reports the end of a sync once, also when it never saw it running', async () => {
|
||||||
let answer = status('OK', '2026-09-30T08:00:00Z');
|
let answer = status('OK', '2026-09-30T08:00:00Z');
|
||||||
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
|
import {fetchInbox, notificationTotal} from '../utils/inbox.js';
|
||||||
|
import {fetchUnseenNews} from '../utils/news.js';
|
||||||
|
import {fetchQuestionCount} from '../utils/questions.js';
|
||||||
|
|
||||||
|
const NONE = {unread: 0, news: 0, questions: 0};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What waits for the user: unread inbox notes, unseen news and open questions, and their sum. Loaded again on every
|
||||||
|
* page change; a call that fails counts 0.
|
||||||
|
*/
|
||||||
|
export default function useNotifications(enabled, pathname) {
|
||||||
|
const [counts, setCounts] = useState(NONE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return undefined;
|
||||||
|
let current = true;
|
||||||
|
Promise.all([fetchInbox(), fetchUnseenNews(), fetchQuestionCount()]).then(([inbox, news, questions]) => {
|
||||||
|
if (!current) return;
|
||||||
|
setCounts({
|
||||||
|
unread: inbox.ok ? inbox.data.unread : 0,
|
||||||
|
news: news.length,
|
||||||
|
questions: questions.ok ? questions.data.count : 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
current = false;
|
||||||
|
};
|
||||||
|
}, [enabled, pathname]);
|
||||||
|
|
||||||
|
const shown = enabled ? counts : NONE;
|
||||||
|
return {...shown, total: notificationTotal(shown)};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
|
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
||||||
|
import useNotifications from './useNotifications.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const news = (...seen) => json(200, {results: seen.map((flag, id) => ({id, seen: flag}))});
|
||||||
|
|
||||||
|
describe('useNotifications', () => {
|
||||||
|
it('sumsUnreadNotesUnseenNewsAndOpenQuestions', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/inbox': json(200, {results: [], unread: 2}),
|
||||||
|
'GET /api/news': news(false, true, false),
|
||||||
|
'GET /api/library/questions/count': json(200, {count: 4}),
|
||||||
|
});
|
||||||
|
const {result} = renderHook(() => useNotifications(true, '/dashboard'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.total).toBe(8));
|
||||||
|
expect(result.current).toEqual({unread: 2, news: 2, questions: 4, total: 8});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aFailedCallCountsZero', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/inbox': json(500, {}),
|
||||||
|
'GET /api/news': json(500, {}),
|
||||||
|
'GET /api/library/questions/count': json(200, {count: 1}),
|
||||||
|
});
|
||||||
|
const {result} = renderHook(() => useNotifications(true, '/dashboard'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.total).toBe(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadsAgainOnAPageChange', async () => {
|
||||||
|
let count = 1;
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/news': news(),
|
||||||
|
'GET /api/library/questions/count': () => json(200, {count}),
|
||||||
|
});
|
||||||
|
const {result, rerender} = renderHook(({path}) => useNotifications(true, path), {initialProps: {path: '/a'}});
|
||||||
|
await waitFor(() => expect(result.current.questions).toBe(1));
|
||||||
|
|
||||||
|
count = 0;
|
||||||
|
rerender({path: '/b'});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.questions).toBe(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asksNothingWhileDisabled', () => {
|
||||||
|
const mock = fetchRoutes({});
|
||||||
|
const {result} = renderHook(() => useNotifications(false, '/'));
|
||||||
|
|
||||||
|
expect(result.current.total).toBe(0);
|
||||||
|
expect(mock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,11 +6,14 @@ const BACKEND_ERROR_KEY = {
|
|||||||
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
|
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Like `useSaveResponse`, but maps backend error codes, shows "saved" and resolves to `{ok}`. */
|
/**
|
||||||
|
* Like `useSaveResponse`, but maps backend error codes, shows "saved" and resolves to `{ok}`. `silent` leaves out
|
||||||
|
* "saved": a switch that shows its new state at once needs no message, only a failure does.
|
||||||
|
*/
|
||||||
export default function usePersistSettings() {
|
export default function usePersistSettings() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
return useCallback(async (send) => {
|
return useCallback(async (send, {silent = false} = {}) => {
|
||||||
try {
|
try {
|
||||||
const response = await send();
|
const response = await send();
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ export default function usePersistSettings() {
|
|||||||
return {ok: false};
|
return {ok: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(t('SETTINGS.SAVED'));
|
if (!silent) toast.success(t('SETTINGS.SAVED'));
|
||||||
return {ok: true};
|
return {ok: true};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Settings save error:', err);
|
console.error('Settings save error:', err);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {useRef, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import {saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `{save, pending, busy}`. `save(field, value)` sends that one field (PATCH .../backlog; 0 or NONE clear it),
|
||||||
|
* shows the saved or error toast and calls `onSaved(entry)` with the whole saved entry. One request at a time: a call
|
||||||
|
* while one runs does nothing. Resolves to true when saved.
|
||||||
|
*
|
||||||
|
* `pending` is `{field, value}` while saving, else null. Show `pending.value` for that field meanwhile: a failed save
|
||||||
|
* then falls back to the saved value by itself.
|
||||||
|
*/
|
||||||
|
export default function useSaveBacklogField(appid, {onSaved} = {}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [pending, setPending] = useState(null);
|
||||||
|
const running = useRef(false);
|
||||||
|
|
||||||
|
const save = async (field, value) => {
|
||||||
|
if (running.current) return false;
|
||||||
|
running.current = true;
|
||||||
|
setPending({field, value});
|
||||||
|
try {
|
||||||
|
const response = await saveBacklogEntry(appid, {[field]: toBacklogRequest({[field]: value})[field]});
|
||||||
|
if (response.status === 401) return false;
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entry = await response.json();
|
||||||
|
onSaved?.(entry);
|
||||||
|
toast.success(t('BACKLOG.SAVED'));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('backlog save error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
running.current = false;
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {save, pending, busy: pending !== null};
|
||||||
|
}
|
||||||