Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6f3d123fb | ||
|
|
8598024bce |
No files matched your search
@@ -34,7 +34,10 @@ To Be Played is split into three repositories:
|
||||
- **Pile Points** (`/points`): 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
|
||||
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG, in `effects.css`),
|
||||
shown on the dashboard and the public profile. The balance is in the header. Every effect stands still with the
|
||||
shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
|
||||
until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more
|
||||
play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS
|
||||
variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still with the
|
||||
system setting "reduce motion" or the "Reduce animations" switch in the settings.
|
||||
- **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.
|
||||
@@ -118,7 +121,13 @@ To Be Played is split into three repositories:
|
||||
and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet.
|
||||
- **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
|
||||
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
|
||||
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them).
|
||||
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them). A hint can
|
||||
target groups (without Steam, role and higher, new users, empty library; all must match). Four automatic hints
|
||||
(no Steam, empty library, pile grew this month, open link queue for auditors) show after the admin hints while
|
||||
their condition is true; the Frontend checks it with Core's library summary, stats and link queue. X hides an
|
||||
automatic hint for 7 days, "Do not show again" for good, the 30 s auto close only for this browser session.
|
||||
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.
|
||||
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
|
||||
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
|
||||
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
|
||||
|
||||
+61
-9
@@ -168,7 +168,8 @@
|
||||
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
|
||||
"FAVORITE_PLATFORM": "Lieblingsplattform",
|
||||
"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. Nimm ihn auf der Punkte-Seite ab."
|
||||
},
|
||||
"VALIDATION": {
|
||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||
@@ -739,13 +740,19 @@
|
||||
"CLOSE": "Schließen",
|
||||
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
|
||||
"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_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_other": "Dein Stapel 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_other": "**{{count}}** Paare warten in der [Verknüpfungs-Warteschlange](/audit/links)."
|
||||
},
|
||||
"ADMIN_HINTS": {
|
||||
"NEW": "Neuer Hinweis",
|
||||
"TEXT_EN": "Text (Englisch)",
|
||||
"TEXT_DE": "Text (Deutsch, optional)",
|
||||
"TEXT_HELP": "Nur Text, höchstens 500 Zeichen. Ohne deutschen Text sehen deutsche Nutzer den englischen.",
|
||||
"TEXT_HELP": "Nur Text, höchstens 500 Zeichen. Mit **fett** und [Text](/pfad) oder [Text](https://...) für Links. Ohne deutschen Text sehen deutsche Nutzer den englischen.",
|
||||
"KIND": "Art",
|
||||
"KIND_DISMISS": "Schließen: geht nach 30 s oder mit X zu",
|
||||
"KIND_ACCEPT": "Bestätigen: bleibt, bis der Nutzer OK klickt",
|
||||
@@ -767,7 +774,26 @@
|
||||
"STATUS_ENDED": "Beendet",
|
||||
"RANGE": "{{start}} bis {{end}}",
|
||||
"DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen",
|
||||
"DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen"
|
||||
"DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen",
|
||||
"TARGET": "Wer es sieht",
|
||||
"TARGET_NO_STEAM": "Nur Nutzer ohne verknüpftes Steam-Konto",
|
||||
"TARGET_EMPTY_LIBRARY": "Nur Nutzer mit leerer Bibliothek",
|
||||
"TARGET_ROLE": "Rolle (diese Rolle und höher)",
|
||||
"TARGET_ROLE_ALL": "Alle Rollen",
|
||||
"TARGET_NEW_DAYS": "Nur Nutzer, die sich in den letzten N Tagen registriert haben (leer: egal)",
|
||||
"TARGET_HELP": "Alle angehakten Gruppen müssen zutreffen. Nichts angehakt: alle Nutzer.",
|
||||
"TARGET_ALL": "Alle Nutzer",
|
||||
"TARGET_SUMMARY_NO_STEAM": "Ohne Steam",
|
||||
"TARGET_SUMMARY_ROLE": "{{role}} und höher",
|
||||
"TARGET_SUMMARY_NEW_one": "Registriert am letzten Tag",
|
||||
"TARGET_SUMMARY_NEW_other": "Registriert in den letzten {{count}} Tagen",
|
||||
"TARGET_SUMMARY_EMPTY_LIBRARY": "Leere Bibliothek",
|
||||
"AUTO_TITLE": "Automatische Hinweise",
|
||||
"AUTO_NO_STEAM": "Kein Steam-Konto verknüpft",
|
||||
"AUTO_EMPTY_LIBRARY": "Leere Bibliothek",
|
||||
"AUTO_PILE_GREW": "Der Stapel ist diesen Monat gewachsen",
|
||||
"AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)",
|
||||
"AUTO_SAVED": "Gespeichert."
|
||||
},
|
||||
"NEWS": {
|
||||
"TITLE": "Neuigkeiten",
|
||||
@@ -806,7 +832,7 @@
|
||||
"TITLE_DE": "Titel (Deutsch, optional)",
|
||||
"TEXT_EN": "Text (Englisch)",
|
||||
"TEXT_DE": "Text (Deutsch, optional)",
|
||||
"TEXT_HELP": "Nur Text. Titel höchstens 100, Text höchstens 2000 Zeichen. Ohne deutschen Text sehen deutsche Nutzer den englischen.",
|
||||
"TEXT_HELP": "Nur Text. Titel höchstens 100, Text höchstens 2000 Zeichen. Im Text geht **fett** und [Text](/pfad) oder [Text](https://...). Ohne deutschen Text sehen deutsche Nutzer den englischen.",
|
||||
"PREVIEW": "Vorschau",
|
||||
"CREATE": "Veröffentlichen",
|
||||
"SAVE": "Speichern",
|
||||
@@ -981,7 +1007,8 @@
|
||||
"COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast.",
|
||||
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche.",
|
||||
"MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
|
||||
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel."
|
||||
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel.",
|
||||
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst."
|
||||
},
|
||||
"SLOTS": {
|
||||
"AVATAR_BORDER": "Avatarrahmen",
|
||||
@@ -990,7 +1017,8 @@
|
||||
"BANNER_FRAME": "Bannerrahmen",
|
||||
"COMPLETED_EFFECT": "Effekt für 100 %",
|
||||
"PAGE_BACKGROUND": "Seitenhintergrund",
|
||||
"BANNER_BACKGROUND": "Bannerhintergrund"
|
||||
"BANNER_BACKGROUND": "Bannerhintergrund",
|
||||
"SKIN": "Skin"
|
||||
},
|
||||
"ITEMS": {
|
||||
"BORDER_GOLD": "Goldener Avatarrahmen",
|
||||
@@ -1020,7 +1048,22 @@
|
||||
"BG_WAVES": "Animierter Hintergrund: Wellen",
|
||||
"BN_WAVES": "Animiertes Banner: Wellen",
|
||||
"BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter",
|
||||
"BN_BOKEH": "Animiertes Banner: Bokeh-Lichter"
|
||||
"BN_BOKEH": "Animiertes Banner: Bokeh-Lichter",
|
||||
"BADGE_FINISH_10": "Beender-Abzeichen",
|
||||
"BORDER_FINISH_25": "Silberner Avatar-Rand",
|
||||
"FRAME_FINISH_50": "Smaragd-Bannerrahmen",
|
||||
"BADGE_PERFECT_5": "Perfektionisten-Abzeichen",
|
||||
"FX_PLATINUM": "Platin-Glanz",
|
||||
"BADGE_STREAK_4": "Serien-Abzeichen",
|
||||
"BORDER_STREAK_12": "Flammen-Avatar-Rand",
|
||||
"FRAME_PILE_DOWN_3": "Türkiser Bannerrahmen",
|
||||
"BADGE_PLAY_MORE_1": "Mehr-spielen-Abzeichen",
|
||||
"BORDER_PLAY_MORE_3": "Himmelblauer Avatar-Rand",
|
||||
"BN_EQUALIZER": "Animiertes Banner: Equalizer",
|
||||
"SKIN_PASTEL": "Skin: Pastell",
|
||||
"SKIN_RETRO_CRT": "Skin: Retro-Röhre",
|
||||
"SKIN_CYBERPUNK": "Skin: Cyberpunk",
|
||||
"SKIN_STEAM": "Skin: Steam"
|
||||
},
|
||||
"KINDS": {
|
||||
"STARTED": "Gestartet",
|
||||
@@ -1030,7 +1073,16 @@
|
||||
"MONTH": "Kleinerer Stapel im",
|
||||
"STREAK": "Serien-Woche",
|
||||
"STARTER": "Startpunkte",
|
||||
"BUY": "Gekauft"
|
||||
"BUY": "Gekauft",
|
||||
"UNLOCK": "Freigeschaltet"
|
||||
},
|
||||
"LOCKED": "Gesperrt, Fortschritt",
|
||||
"PROGRESS": {
|
||||
"GAMES_FINISHED": "{{count}} / {{target}} Spiele beendet",
|
||||
"GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %",
|
||||
"FINISH_WEEKS": "{{count}} / {{target}} Wochen in Folge mit einem beendeten Spiel",
|
||||
"PILE_DOWN_MONTHS": "{{count}} / {{target}} Monate in Folge mit kleinerem Stapel",
|
||||
"PLAY_MORE_MONTHS": "{{count}} / {{target}} Monate in Folge mit mehr Spielzeit als im Monat davor"
|
||||
}
|
||||
},
|
||||
"UNSAVED_CHANGES": {
|
||||
|
||||
+61
-9
@@ -168,7 +168,8 @@
|
||||
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
|
||||
"FAVORITE_PLATFORM": "Favorite 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 Points page."
|
||||
},
|
||||
"VALIDATION": {
|
||||
"EMAIL_REQUIRED": "Email is required.",
|
||||
@@ -739,13 +740,19 @@
|
||||
"CLOSE": "Close",
|
||||
"DO_NOT_SHOW_AGAIN": "Do not show again",
|
||||
"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_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_other": "Your pile grew by **{{count}}** games this month. [See the trend](/stats).",
|
||||
"AUTO_LINK_QUEUE_one": "**{{count}}** pair waits in the [link queue](/audit/links).",
|
||||
"AUTO_LINK_QUEUE_other": "**{{count}}** pairs wait in the [link queue](/audit/links)."
|
||||
},
|
||||
"ADMIN_HINTS": {
|
||||
"NEW": "New hint",
|
||||
"TEXT_EN": "Text (English)",
|
||||
"TEXT_DE": "Text (German, optional)",
|
||||
"TEXT_HELP": "Plain text, max 500 characters. Without a German text, German users see the English one.",
|
||||
"TEXT_HELP": "Plain text, max 500 characters. Use **bold** and [text](/path) or [text](https://...) for links. Without a German text, German users see the English one.",
|
||||
"KIND": "Type",
|
||||
"KIND_DISMISS": "Dismiss: closes after 30 s or with X",
|
||||
"KIND_ACCEPT": "Accept: stays until the user clicks OK",
|
||||
@@ -767,7 +774,26 @@
|
||||
"STATUS_ENDED": "Ended",
|
||||
"RANGE": "{{start}} to {{end}}",
|
||||
"DONE_COUNT_one": "Closed for good by {{count}} user",
|
||||
"DONE_COUNT_other": "Closed for good by {{count}} users"
|
||||
"DONE_COUNT_other": "Closed for good by {{count}} users",
|
||||
"TARGET": "Who sees it",
|
||||
"TARGET_NO_STEAM": "Only users without a linked Steam account",
|
||||
"TARGET_EMPTY_LIBRARY": "Only users with an empty library",
|
||||
"TARGET_ROLE": "Role (this role and higher)",
|
||||
"TARGET_ROLE_ALL": "All roles",
|
||||
"TARGET_NEW_DAYS": "Only users registered in the last N days (empty: any)",
|
||||
"TARGET_HELP": "All ticked groups must match. Nothing ticked: all users.",
|
||||
"TARGET_ALL": "All users",
|
||||
"TARGET_SUMMARY_NO_STEAM": "Without Steam",
|
||||
"TARGET_SUMMARY_ROLE": "{{role}} and higher",
|
||||
"TARGET_SUMMARY_NEW_one": "Registered in the last day",
|
||||
"TARGET_SUMMARY_NEW_other": "Registered in the last {{count}} days",
|
||||
"TARGET_SUMMARY_EMPTY_LIBRARY": "Empty library",
|
||||
"AUTO_TITLE": "Automatic hints",
|
||||
"AUTO_NO_STEAM": "No Steam account linked",
|
||||
"AUTO_EMPTY_LIBRARY": "Empty library",
|
||||
"AUTO_PILE_GREW": "The pile grew this month",
|
||||
"AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)",
|
||||
"AUTO_SAVED": "Saved."
|
||||
},
|
||||
"NEWS": {
|
||||
"TITLE": "What's new",
|
||||
@@ -806,7 +832,7 @@
|
||||
"TITLE_DE": "Title (German, optional)",
|
||||
"TEXT_EN": "Text (English)",
|
||||
"TEXT_DE": "Text (German, optional)",
|
||||
"TEXT_HELP": "Plain text. Title max 100, text max 2000 characters. Without a German text, German users see the English one.",
|
||||
"TEXT_HELP": "Plain text. Title max 100, text max 2000 characters. Use **bold** and [text](/path) or [text](https://...) in the text. Without a German text, German users see the English one.",
|
||||
"PREVIEW": "Preview",
|
||||
"CREATE": "Publish",
|
||||
"SAVE": "Save",
|
||||
@@ -981,7 +1007,8 @@
|
||||
"COMPLETED": "+40 points when you reach 100 % achievements in a completed game.",
|
||||
"FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week.",
|
||||
"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.",
|
||||
"STREAK": "20 points for every week with a finished game after a week with a finished game."
|
||||
"STREAK": "20 points for every week with a finished game after a week with a finished game.",
|
||||
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal."
|
||||
},
|
||||
"SLOTS": {
|
||||
"AVATAR_BORDER": "Avatar border",
|
||||
@@ -990,7 +1017,8 @@
|
||||
"BANNER_FRAME": "Banner frame",
|
||||
"COMPLETED_EFFECT": "Completed effect",
|
||||
"PAGE_BACKGROUND": "Page background",
|
||||
"BANNER_BACKGROUND": "Banner background"
|
||||
"BANNER_BACKGROUND": "Banner background",
|
||||
"SKIN": "Skin"
|
||||
},
|
||||
"ITEMS": {
|
||||
"BORDER_GOLD": "Golden avatar border",
|
||||
@@ -1020,7 +1048,22 @@
|
||||
"BG_WAVES": "Animated background: waves",
|
||||
"BN_WAVES": "Animated banner: waves",
|
||||
"BG_BOKEH": "Animated background: bokeh",
|
||||
"BN_BOKEH": "Animated banner: bokeh"
|
||||
"BN_BOKEH": "Animated banner: bokeh",
|
||||
"BADGE_FINISH_10": "Finisher badge",
|
||||
"BORDER_FINISH_25": "Silver avatar border",
|
||||
"FRAME_FINISH_50": "Emerald banner frame",
|
||||
"BADGE_PERFECT_5": "Perfectionist badge",
|
||||
"FX_PLATINUM": "Platinum shine",
|
||||
"BADGE_STREAK_4": "Streak badge",
|
||||
"BORDER_STREAK_12": "Flame avatar border",
|
||||
"FRAME_PILE_DOWN_3": "Teal banner frame",
|
||||
"BADGE_PLAY_MORE_1": "Play more badge",
|
||||
"BORDER_PLAY_MORE_3": "Sky avatar border",
|
||||
"BN_EQUALIZER": "Animated banner: equalizer",
|
||||
"SKIN_PASTEL": "Skin: Pastel",
|
||||
"SKIN_RETRO_CRT": "Skin: Retro CRT",
|
||||
"SKIN_CYBERPUNK": "Skin: Cyberpunk",
|
||||
"SKIN_STEAM": "Skin: Steam"
|
||||
},
|
||||
"KINDS": {
|
||||
"STARTED": "Started",
|
||||
@@ -1030,7 +1073,16 @@
|
||||
"MONTH": "Smaller pile in",
|
||||
"STREAK": "Streak week",
|
||||
"STARTER": "Starter points",
|
||||
"BUY": "Bought"
|
||||
"BUY": "Bought",
|
||||
"UNLOCK": "Unlocked"
|
||||
},
|
||||
"LOCKED": "Locked, progress",
|
||||
"PROGRESS": {
|
||||
"GAMES_FINISHED": "{{count}} / {{target}} games finished",
|
||||
"GAMES_COMPLETED": "{{count}} / {{target}} games at 100 %",
|
||||
"FINISH_WEEKS": "{{count}} / {{target}} weeks in a row with a finish",
|
||||
"PILE_DOWN_MONTHS": "{{count}} / {{target}} months in a row with a smaller pile",
|
||||
"PLAY_MORE_MONTHS": "{{count}} / {{target}} months in a row with more play time than the month before"
|
||||
}
|
||||
},
|
||||
"UNSAVED_CHANGES": {
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ function App() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{/* isolate: the background layer sits behind the content but in front of this div's own background. */}
|
||||
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
|
||||
<div className="isolate min-h-screen flex flex-col bg-background skin-bg text-foreground font-sans">
|
||||
{/* A public profile shows the owner's background instead. */}
|
||||
{isAuthenticated && !location.pathname.startsWith('/u/') && (
|
||||
<PageBackground appid={appearance.backgroundAppid} effect={backgroundEffect(equipped)}/>
|
||||
|
||||
@@ -6,13 +6,14 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
|
||||
import {equippedSkin} from '../utils/rewards.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
|
||||
export default function AppearanceCard() {
|
||||
const {t} = useTranslation();
|
||||
const {appearance} = usePreferences();
|
||||
const {appearance, equipped} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
|
||||
return (
|
||||
@@ -23,6 +24,7 @@ export default function AppearanceCard() {
|
||||
<CardContent className="space-y-6">
|
||||
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
|
||||
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
|
||||
{equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import AppearanceCard from './AppearanceCard.jsx';
|
||||
|
||||
const preferences = vi.hoisted(() => ({value: null}));
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../context/PreferencesContext.jsx', () => ({usePreferences: () => preferences.value}));
|
||||
vi.mock('../hooks/useSaveAppearance.js', () => ({default: () => vi.fn()}));
|
||||
|
||||
const show = (equipped) => {
|
||||
preferences.value = {
|
||||
appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion: false, showBanner: true},
|
||||
equipped,
|
||||
};
|
||||
render(<AppearanceCard/>);
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AppearanceCard', () => {
|
||||
it('tells that the skin sets colors and corners only while a skin is worn', () => {
|
||||
show({SKIN: 'SKIN_STEAM'});
|
||||
expect(screen.getByText('SETTINGS.SKIN_OVERRIDES')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no skin hint without a skin', () => {
|
||||
show({});
|
||||
expect(screen.queryByText('SETTINGS.SKIN_OVERRIDES')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import HintCard from './HintCard.jsx';
|
||||
import {fetchHints, markHintDone} from '../utils/hints.js';
|
||||
import {AUTO_HINTS, fetchHints, hideAutoHint, markHintDone, pileGrowth, snoozeAutoHint} from '../utils/hints.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {fetchLinkQueue} from '../utils/links.js';
|
||||
|
||||
export const AUTO_CLOSE_MS = 30_000;
|
||||
const CLOSED_KEY = 'tbp_closed_hints';
|
||||
@@ -26,34 +28,75 @@ const rememberClosed = (hintId) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The admin hints on the dashboard, one at a time, newest first. A DISMISS hint closed without "Do not show again"
|
||||
* comes back in the next browser session.
|
||||
* 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
|
||||
* hint hides it for 7 days, "Do not show again" for good.
|
||||
*
|
||||
* @param user the current user
|
||||
* @param summary Core's library summary; undefined while loading, null when it failed
|
||||
* @param trend the TREND group of the stats; undefined while loading, null when it failed
|
||||
*/
|
||||
export default function DashboardHint() {
|
||||
export default function DashboardHint({user, summary, trend}) {
|
||||
const {t} = useTranslation();
|
||||
const [hints, setHints] = useState([]);
|
||||
const [data, setData] = useState({hints: [], automatic: []});
|
||||
const [closed, setClosed] = useState(closedIds);
|
||||
const [hideForever, setHideForever] = useState(false);
|
||||
const hint = hints[0];
|
||||
const [linkCount, setLinkCount] = useState();
|
||||
const wantsLinkQueue = data.automatic.includes('LINK_QUEUE') && canCorrect(user);
|
||||
|
||||
useEffect(() => {
|
||||
// A failed load shows nothing, not an error.
|
||||
fetchHints()
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
const closed = closedIds();
|
||||
setHints((data?.results ?? []).filter((hint) => !closed.includes(hint.id)));
|
||||
})
|
||||
.then((body) => setData({hints: body?.results ?? [], automatic: body?.automatic ?? []}))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsLinkQueue) return;
|
||||
// The queue is an AUDITOR page: a failure or 403 counts as an empty queue.
|
||||
fetchLinkQueue({page: 0, size: 20})
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((body) => setLinkCount(body?.totalElements ?? 0))
|
||||
.catch(() => setLinkCount(0));
|
||||
}, [wantsLinkQueue]);
|
||||
|
||||
const hint = useMemo(() => {
|
||||
const {hints, automatic} = data;
|
||||
const links = wantsLinkQueue ? linkCount : 0;
|
||||
const needsSummary = hints.some((h) => h.emptyLibraryOnly) || automatic.includes('EMPTY_LIBRARY');
|
||||
// Show nothing until every fact the cards need has settled, so a later fact does not swap the card.
|
||||
if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined)
|
||||
|| (automatic.includes('NO_STEAM') && !user) || links === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const libraryEmpty = summary?.total === 0;
|
||||
const growth = pileGrowth(trend);
|
||||
const condition = {NO_STEAM: !user?.steamId, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0};
|
||||
const count = {LINK_QUEUE: links, PILE_GREW: growth};
|
||||
return [
|
||||
...hints.filter((h) => !h.emptyLibraryOnly || libraryEmpty),
|
||||
...AUTO_HINTS.filter((key) => automatic.includes(key) && condition[key]).map((key) => ({
|
||||
id: `auto:${key}`, auto: key, kind: 'DISMISS', text: t(`HINT.AUTO_${key}`, {count: count[key]}),
|
||||
})),
|
||||
].find((h) => !closed.includes(h.id));
|
||||
}, [data, closed, summary, trend, user, linkCount, wantsLinkQueue, t]);
|
||||
|
||||
// A failed save only shows an error: the hint comes back in the next browser session.
|
||||
const close = useCallback(async (done) => {
|
||||
const close = useCallback(async (forever, byUser) => {
|
||||
rememberClosed(hint.id);
|
||||
setHints((current) => current.slice(1));
|
||||
setClosed((current) => [...current, hint.id]);
|
||||
setHideForever(false);
|
||||
if (!done) return;
|
||||
let save = null;
|
||||
if (hint.auto) {
|
||||
if (forever) save = () => hideAutoHint(hint.auto);
|
||||
else if (byUser) save = () => snoozeAutoHint(hint.auto);
|
||||
} else if (forever) {
|
||||
save = () => markHintDone(hint.id);
|
||||
}
|
||||
if (!save) return;
|
||||
try {
|
||||
const response = await markHintDone(hint.id);
|
||||
const response = await save();
|
||||
if (!response.ok) toast.error(t('HINT.SAVE_FAILED'));
|
||||
} catch {
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
@@ -62,7 +105,7 @@ export default function DashboardHint() {
|
||||
|
||||
useEffect(() => {
|
||||
if (hint?.kind !== 'DISMISS') return undefined;
|
||||
const timer = setTimeout(() => close(hideForever), AUTO_CLOSE_MS);
|
||||
const timer = setTimeout(() => close(hideForever, false), AUTO_CLOSE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hint, hideForever, close]);
|
||||
|
||||
@@ -71,8 +114,8 @@ export default function DashboardHint() {
|
||||
// The next card shows under the same pointer: the second click of a double click must not close it unseen.
|
||||
return (
|
||||
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
|
||||
onClose={(event) => event.detail < 2 && close(hideForever)}
|
||||
onAccept={(event) => event.detail < 2 && close(true)}
|
||||
onClose={(event) => event.detail < 2 && close(hideForever, true)}
|
||||
onAccept={(event) => event.detail < 2 && close(true, true)}
|
||||
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
|
||||
import {hintText} from '../utils/hints.js';
|
||||
import {hintText, pileGrowth} from '../utils/hints.js';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -12,14 +13,29 @@ vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
|
||||
const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'};
|
||||
const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'};
|
||||
|
||||
const hintRoutes = (list, done = json(204)) => fetchRoutes({
|
||||
const USER = {steamId: '1', activeRole: 'USER'};
|
||||
const AUDITOR = {steamId: '1', activeRole: 'AUDITOR'};
|
||||
const NO_TREND = {days: []};
|
||||
const GREW = {days: [{day: '2026-10-01', backlogGames: 10}, {day: '2026-10-02', backlogGames: 12}]};
|
||||
|
||||
const hintRoutes = (list, done = json(204), more = {}) => fetchRoutes({
|
||||
'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,
|
||||
});
|
||||
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
|
||||
|
||||
const mount = (props = {}) => render(
|
||||
<MemoryRouter><DashboardHint user={USER} summary={{total: 5}} trend={NO_TREND} {...props}/></MemoryRouter>);
|
||||
|
||||
const showHints = async (...hints) => {
|
||||
hintRoutes(json(200, {results: hints}));
|
||||
render(<DashboardHint/>);
|
||||
mount();
|
||||
await act(async () => undefined);
|
||||
};
|
||||
|
||||
const showAuto = async (automatic, props = {}, results = [], more = {}) => {
|
||||
hintRoutes(json(200, {results, automatic}), json(204), more);
|
||||
mount(props);
|
||||
await act(async () => undefined);
|
||||
};
|
||||
|
||||
@@ -50,7 +66,7 @@ describe('DashboardHint', () => {
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
cleanup();
|
||||
|
||||
render(<DashboardHint/>);
|
||||
mount();
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.queryByText(DISMISS.textDe)).toBeNull();
|
||||
@@ -92,7 +108,7 @@ describe('DashboardHint', () => {
|
||||
|
||||
it('shows an error when the answer is not stored', async () => {
|
||||
hintRoutes(json(200, {results: [ACCEPT]}), json(500));
|
||||
render(<DashboardHint/>);
|
||||
mount();
|
||||
await act(async () => undefined);
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByText('HINT.OK')));
|
||||
@@ -113,7 +129,7 @@ describe('DashboardHint', () => {
|
||||
|
||||
it('shows nothing when the hints do not load', async () => {
|
||||
hintRoutes(json(500));
|
||||
render(<DashboardHint/>);
|
||||
mount();
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
@@ -121,6 +137,162 @@ describe('DashboardHint', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardHint, automatic hints', () => {
|
||||
beforeEach(() => vi.useFakeTimers({shouldAdvanceTime: true}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the admin hints before the automatic ones', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}, [DISMISS]);
|
||||
|
||||
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
|
||||
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows NO_STEAM only without a Steam ID', async () => {
|
||||
await showAuto(['NO_STEAM']);
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides an automatic hint for 7 days with X', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/snooze')]);
|
||||
});
|
||||
|
||||
it('hides an automatic hint for good with the tick', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
|
||||
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
|
||||
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
|
||||
});
|
||||
|
||||
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}});
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(doneCalls()).toEqual([]);
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the auto closed hint for good when the tick is set', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
|
||||
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
|
||||
|
||||
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
|
||||
});
|
||||
|
||||
it('does not show an automatic hint that Auth did not send', async () => {
|
||||
await showAuto([], {user: {...USER, steamId: null}, summary: {total: 0}});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows EMPTY_LIBRARY only with an empty library', async () => {
|
||||
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 3}});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 0}});
|
||||
expect(screen.getByText('HINT.AUTO_EMPTY_LIBRARY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an admin hint with emptyLibraryOnly only with an empty library', async () => {
|
||||
const only = {...DISMISS, emptyLibraryOnly: true};
|
||||
await showAuto([], {summary: {total: 3}}, [only]);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
await showAuto([], {summary: {total: 0}}, [only]);
|
||||
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows nothing while a fact it needs is loading', async () => {
|
||||
await showAuto(['PILE_GREW'], {trend: undefined});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
await showAuto(['EMPTY_LIBRARY'], {summary: undefined});
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows PILE_GREW with the growth since the first day of the month', async () => {
|
||||
await showAuto(['PILE_GREW'], {trend: GREW});
|
||||
|
||||
expect(screen.getByText('HINT.AUTO_PILE_GREW:2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not ask the link queue below AUDITOR', async () => {
|
||||
await showAuto(['LINK_QUEUE'], {user: USER});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(fetch.mock.calls.some(([url]) => url.includes('link-queue'))).toBe(false);
|
||||
});
|
||||
|
||||
it('shows LINK_QUEUE to an auditor with the number of pairs', async () => {
|
||||
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
|
||||
{'GET /api/library/link-queue': json(200, {content: [], totalElements: 3})});
|
||||
|
||||
expect(screen.getByText('HINT.AUTO_LINK_QUEUE:3')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no LINK_QUEUE hint when the queue is empty or refused', async () => {
|
||||
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
|
||||
{'GET /api/library/link-queue': json(403, {})});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows only the admin hints when Auth sends no automatic list', async () => {
|
||||
hintRoutes(json(200, {results: [DISMISS]}));
|
||||
mount({user: {...USER, steamId: null}});
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pileGrowth', () => {
|
||||
it('counts from the first day of the last month and is 0 without a second day', () => {
|
||||
expect(pileGrowth(GREW)).toBe(2);
|
||||
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-02', backlogGames: 4}]})).toBe(-1);
|
||||
expect(pileGrowth({days: [{day: '2026-10-02', backlogGames: 4}]})).toBe(0);
|
||||
expect(pileGrowth({days: [{day: '2026-10-01', backlogGames: 9}, {day: '2026-10-02', backlogGames: 7}]})).toBe(-2);
|
||||
expect(pileGrowth(null)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts games added on the first day of the month', () => {
|
||||
// Pile 5 at the end of September, 3 games added on October 1, nothing after.
|
||||
const trend = {days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8},
|
||||
{day: '2026-10-02', backlogGames: 8}]};
|
||||
expect(pileGrowth(trend)).toBe(3);
|
||||
// On October 1 itself: last month's snapshot and today's live value.
|
||||
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8}]})).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hintText', () => {
|
||||
it('takes the German text for a German UI only when there is one', () => {
|
||||
expect(hintText(DISMISS, 'de-DE')).toBe(DISMISS.textDe);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh'];
|
||||
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh', 'equalizer'];
|
||||
|
||||
const range = (count) => Array.from({length: count}, (_, i) => i);
|
||||
|
||||
@@ -73,6 +73,8 @@ function shapes(effect) {
|
||||
<path d={WAVE} fill="currentColor"/>
|
||||
</svg>
|
||||
));
|
||||
case 'equalizer':
|
||||
return range(16).map((i) => <span key={i}/>);
|
||||
case 'bokeh':
|
||||
return range(10).map((i) => (
|
||||
<span key={i} style={{
|
||||
|
||||
@@ -21,7 +21,7 @@ import {coverEffect, detailEffect} from '../utils/rewards.js';
|
||||
|
||||
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
||||
// 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};
|
||||
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}) {
|
||||
const {t} = useTranslation();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Megaphone, X} from 'lucide-react';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import HintText from './HintText.jsx';
|
||||
import {hintText} from '../utils/hints.js';
|
||||
|
||||
export default function HintCard({hint, hideForever, onHideForeverChange, onClose, onAccept, className}) {
|
||||
@@ -15,7 +16,9 @@ export default function HintCard({hint, hideForever, onHideForeverChange, onClos
|
||||
<Card role="status" className={cn('gap-3 p-4 shadow-lg', className)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary"/>
|
||||
<p className="flex-1 whitespace-pre-line break-words text-sm">{hintText(hint, i18n.language)}</p>
|
||||
<p className="flex-1 whitespace-pre-line break-words text-sm">
|
||||
<HintText text={hint.text ?? hintText(hint, i18n.language)}/>
|
||||
</p>
|
||||
{hint.kind === 'DISMISS' && (
|
||||
<Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8"
|
||||
aria-label={t('HINT.CLOSE')} onClick={onClose}>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
// **bold** and [text](url) only. No nesting: the link text is plain.
|
||||
const TOKEN = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\(([^)\s]+)\)/g;
|
||||
// An app path (not //host, and no backslash: browsers read /\host as //host), or https. Case-sensitive on purpose.
|
||||
const APP_PATH = /^\/(?![/\\])[^\s\\]*$/;
|
||||
const HTTPS = /^https:\/\/[^\s\\]+$/;
|
||||
|
||||
/**
|
||||
* @returns {{to: string} | {href: string} | null} null: the url is not safe, so the link stays plain text
|
||||
*/
|
||||
export const safeLink = (url) => {
|
||||
if (APP_PATH.test(url)) return {to: url};
|
||||
if (HTTPS.test(url)) return {href: url};
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hint and news text with **bold** and [text](url). Only React elements, never HTML: React escapes every string.
|
||||
*/
|
||||
export default function HintText({text}) {
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(TOKEN)) {
|
||||
const [whole, bold, label, url] = match;
|
||||
if (match.index > last) parts.push(text.slice(last, match.index));
|
||||
last = match.index + whole.length;
|
||||
const link = url && safeLink(url);
|
||||
if (bold !== undefined) {
|
||||
parts.push(<strong key={match.index}>{bold}</strong>);
|
||||
} else if (link?.to) {
|
||||
parts.push(<Link key={match.index} to={link.to} className="underline">{label}</Link>);
|
||||
} else if (link) {
|
||||
parts.push(<a key={match.index} href={link.href} target="_blank" rel="noopener noreferrer"
|
||||
className="underline">{label}</a>);
|
||||
} else {
|
||||
parts.push(whole);
|
||||
}
|
||||
}
|
||||
parts.push(text.slice(last));
|
||||
return <>{parts}</>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import {cleanup, render} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import HintText, {safeLink} from './HintText.jsx';
|
||||
|
||||
const renderText = (text) => render(<MemoryRouter><p>{<HintText text={text}/>}</p></MemoryRouter>).container;
|
||||
|
||||
const UNSAFE = [
|
||||
'javascript:alert(1)', 'JavaScript:alert(1)', 'data:text/html,<b>x</b>', 'http://example.com', '//evil.example',
|
||||
'/\\evil.example', 'HTTPS://x', 'vbscript:x',
|
||||
];
|
||||
|
||||
describe('HintText', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('renders bold and links', () => {
|
||||
const container = renderText('A **bold** word, [Import](/import) and [site](https://example.com).');
|
||||
|
||||
expect(container.querySelector('strong').textContent).toBe('bold');
|
||||
expect(container.querySelector('a[href="/import"]').textContent).toBe('Import');
|
||||
const external = container.querySelector('a[href="https://example.com"]');
|
||||
expect(external.getAttribute('target')).toBe('_blank');
|
||||
expect(external.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
expect(container.textContent).toBe('A bold word, Import and site.');
|
||||
});
|
||||
|
||||
it.each(UNSAFE)('shows a link to %s as plain text', (url) => {
|
||||
const text = `[click](${url})`;
|
||||
const container = renderText(text);
|
||||
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
expect(container.textContent).toBe(text);
|
||||
});
|
||||
|
||||
it('shows HTML as text', () => {
|
||||
const container = renderText('<img src=x onerror=alert(1)> <script>alert(1)</script>');
|
||||
|
||||
expect(container.querySelector('img, script')).toBeNull();
|
||||
expect(container.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('keeps unclosed markup and line breaks as they are', () => {
|
||||
const container = renderText('**bold\n[text](');
|
||||
|
||||
expect(container.querySelector('strong, a')).toBeNull();
|
||||
expect(container.textContent).toBe('**bold\n[text](');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeLink', () => {
|
||||
it('allows app paths and https only', () => {
|
||||
expect(safeLink('/import')).toEqual({to: '/import'});
|
||||
expect(safeLink('https://example.com/a')).toEqual({href: 'https://example.com/a'});
|
||||
UNSAFE.forEach((url) => expect(safeLink(url)).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
import HintText from './HintText.jsx';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {newsText, newsTitle} from '../utils/news.js';
|
||||
|
||||
@@ -11,7 +12,7 @@ export default function NewsEntry({entry, className}) {
|
||||
<article className={cn('space-y-1', className)}>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(entry.publishedAt, i18n.language, {dateStyle: 'medium'})}</p>
|
||||
<h3 className="font-semibold">{newsTitle(entry, i18n.language)}</h3>
|
||||
<p className="whitespace-pre-line break-words text-sm">{newsText(entry, i18n.language)}</p>
|
||||
<p className="whitespace-pre-line break-words text-sm"><HintText text={newsText(entry, i18n.language)}/></p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ function Card({
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-(--card-shadow)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import useSession from '../hooks/useSession.js';
|
||||
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||
import {cacheEquipped, equippedAccent, loadWallet, readCachedEquipped} from '../utils/rewards.js';
|
||||
import {cacheEquipped, equippedAccent, equippedSkin, loadWallet, readCachedEquipped} from '../utils/rewards.js';
|
||||
import {storage} from '../utils/storage.js';
|
||||
|
||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||
@@ -57,7 +57,7 @@ export function PreferencesProvider({children}) {
|
||||
|
||||
// Layout effect: the cached look is set before the first paint.
|
||||
useLayoutEffect(() => {
|
||||
applyAppearance(appearance, equippedAccent(equipped));
|
||||
applyAppearance(appearance, {accent: equippedAccent(equipped), skin: equippedSkin(equipped)});
|
||||
cacheAppearance(appearance);
|
||||
cacheEquipped(equipped);
|
||||
}, [appearance, equipped]);
|
||||
|
||||
@@ -137,4 +137,15 @@ describe('PreferencesProvider worn looks', () => {
|
||||
|
||||
expect(preferences.equipped).toEqual({});
|
||||
});
|
||||
|
||||
it('wears the skin on the root and takes it off on reset', () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
localStorage.setItem('tbp_equipped', JSON.stringify({SKIN: 'SKIN_RETRO_CRT'}));
|
||||
|
||||
render(<PreferencesProvider><Spy/></PreferencesProvider>);
|
||||
expect(document.documentElement.dataset.skin).toBe('retro-crt');
|
||||
|
||||
act(() => preferences.resetAppearance());
|
||||
expect(document.documentElement.dataset.skin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
/* ---- COMPLETED_EFFECT, cover of a tile (GameCover has relative and overflow-hidden) ---- */
|
||||
|
||||
.fx-cover-gold-shine::after,
|
||||
.fx-cover-platinum::after,
|
||||
.fx-cover-neon-pulse::after,
|
||||
.fx-cover-holo::after,
|
||||
.fx-cover-ember::before,
|
||||
@@ -33,6 +34,14 @@
|
||||
animation: fx-sweep 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The silver border is an inside outline in the look; a cool light sweeps over the cover. */
|
||||
.fx-cover-platinum::after {
|
||||
inset: 0;
|
||||
background: linear-gradient(115deg, transparent 38%, rgb(226 232 240 / 0.4) 48%, rgb(186 230 253 / 0.3) 54%, transparent 62%);
|
||||
transform: translateX(-100%);
|
||||
animation: fx-sweep 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The ring is an inside outline in the look; the glow is inside, because the cover clips outside shadows. */
|
||||
.fx-cover-neon-pulse::after {
|
||||
inset: 0;
|
||||
@@ -98,6 +107,26 @@
|
||||
.fx-detail-gold-shine > :nth-child(7) { --x: 70%; --y: 44%; --d: -2.4s; }
|
||||
.fx-detail-gold-shine > :nth-child(8) { --x: 40%; --y: 82%; --d: -0.2s; }
|
||||
|
||||
.fx-detail-platinum > span {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: var(--y);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #6b7a90;
|
||||
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
|
||||
transform: scale(0.6);
|
||||
animation: fx-twinkle 3s ease-in-out infinite;
|
||||
animation-delay: var(--d);
|
||||
}
|
||||
.dark .fx-detail-platinum > span { background: #e2e8f0; }
|
||||
.fx-detail-platinum > :nth-child(1) { --x: 48%; --y: 16%; --d: 0s; }
|
||||
.fx-detail-platinum > :nth-child(2) { --x: 66%; --y: 64%; --d: -0.5s; }
|
||||
.fx-detail-platinum > :nth-child(3) { --x: 80%; --y: 22%; --d: -1s; }
|
||||
.fx-detail-platinum > :nth-child(4) { --x: 90%; --y: 58%; --d: -1.5s; }
|
||||
.fx-detail-platinum > :nth-child(5) { --x: 56%; --y: 44%; --d: -2s; }
|
||||
.fx-detail-platinum > :nth-child(6) { --x: 84%; --y: 84%; --d: -2.5s; }
|
||||
|
||||
.fx-detail-neon-pulse > span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -333,6 +362,37 @@
|
||||
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent);
|
||||
}
|
||||
|
||||
/* equalizer: 16 bars at the bottom, scaleY only. The heights are fixed, so frame 0 is a static skyline. */
|
||||
@keyframes fx-bar { from { transform: scaleY(var(--lo)); } to { transform: scaleY(var(--hi)); } }
|
||||
.fx-bg-equalizer > * {
|
||||
bottom: 0;
|
||||
left: calc(var(--i) * 6.25% + 1%);
|
||||
width: 4.25%;
|
||||
height: 55%;
|
||||
transform-origin: bottom;
|
||||
transform: scaleY(var(--hi));
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: linear-gradient(to top, color-mix(in oklab, var(--primary) 35%, transparent), color-mix(in oklab, var(--logo-teal) 12%, transparent));
|
||||
animation: fx-bar var(--dur) ease-in-out infinite alternate;
|
||||
animation-delay: var(--d);
|
||||
}
|
||||
.fx-bg-equalizer > :nth-child(1) { --i: 0; --hi: 0.55; --lo: 0.2; --dur: 1.1s; --d: 0s; }
|
||||
.fx-bg-equalizer > :nth-child(2) { --i: 1; --hi: 0.8; --lo: 0.3; --dur: 1.4s; --d: -0.3s; }
|
||||
.fx-bg-equalizer > :nth-child(3) { --i: 2; --hi: 0.4; --lo: 0.15; --dur: 0.9s; --d: -0.6s; }
|
||||
.fx-bg-equalizer > :nth-child(4) { --i: 3; --hi: 0.95; --lo: 0.4; --dur: 1.7s; --d: -0.2s; }
|
||||
.fx-bg-equalizer > :nth-child(5) { --i: 4; --hi: 0.65; --lo: 0.25; --dur: 1.2s; --d: -0.9s; }
|
||||
.fx-bg-equalizer > :nth-child(6) { --i: 5; --hi: 0.3; --lo: 0.1; --dur: 0.8s; --d: -0.4s; }
|
||||
.fx-bg-equalizer > :nth-child(7) { --i: 6; --hi: 0.75; --lo: 0.35; --dur: 1.5s; --d: -1.1s; }
|
||||
.fx-bg-equalizer > :nth-child(8) { --i: 7; --hi: 1; --lo: 0.45; --dur: 1.9s; --d: -0.7s; }
|
||||
.fx-bg-equalizer > :nth-child(9) { --i: 8; --hi: 0.5; --lo: 0.2; --dur: 1s; --d: -1.3s; }
|
||||
.fx-bg-equalizer > :nth-child(10) { --i: 9; --hi: 0.85; --lo: 0.3; --dur: 1.6s; --d: -0.1s; }
|
||||
.fx-bg-equalizer > :nth-child(11) { --i: 10; --hi: 0.35; --lo: 0.12; --dur: 0.85s; --d: -0.8s; }
|
||||
.fx-bg-equalizer > :nth-child(12) { --i: 11; --hi: 0.7; --lo: 0.28; --dur: 1.3s; --d: -1.5s; }
|
||||
.fx-bg-equalizer > :nth-child(13) { --i: 12; --hi: 0.9; --lo: 0.4; --dur: 1.8s; --d: -0.5s; }
|
||||
.fx-bg-equalizer > :nth-child(14) { --i: 13; --hi: 0.45; --lo: 0.18; --dur: 1.05s; --d: -1.2s; }
|
||||
.fx-bg-equalizer > :nth-child(15) { --i: 14; --hi: 0.6; --lo: 0.22; --dur: 1.25s; --d: -0.35s; }
|
||||
.fx-bg-equalizer > :nth-child(16) { --i: 15; --hi: 0.25; --lo: 0.1; --dur: 0.95s; --d: -1.0s; }
|
||||
|
||||
/* ---- Off screen covers hold their frame (components/GameCover.jsx) ---- */
|
||||
|
||||
.fx-paused,
|
||||
|
||||
@@ -12,7 +12,8 @@ import {Textarea} from '@/components/ui/textarea';
|
||||
import HintCard from '../components/HintCard.jsx';
|
||||
import {formatDateTime} from '../utils/format.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
|
||||
import {createHint, endHint, fetchAdminHints, setAutoHintEnabled} from '../utils/hints.js';
|
||||
import {ROLES} from '../utils/admin.js';
|
||||
import {MAX_HINT_LENGTH} from '../utils/limits.js';
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -25,7 +26,18 @@ const toLocalInput = (date) => {
|
||||
|
||||
const emptyForm = () => {
|
||||
const now = new Date();
|
||||
return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS))};
|
||||
return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS)),
|
||||
targetNoSteam: false, targetRole: '', targetNewDays: '', targetEmptyLibrary: false};
|
||||
};
|
||||
|
||||
const targetSummary = (hint, t) => {
|
||||
const groups = [
|
||||
hint.targetNoSteam && t('ADMIN_HINTS.TARGET_SUMMARY_NO_STEAM'),
|
||||
hint.targetRole && t('ADMIN_HINTS.TARGET_SUMMARY_ROLE', {role: t(`ROLES.${hint.targetRole}`)}),
|
||||
hint.targetNewDays && t('ADMIN_HINTS.TARGET_SUMMARY_NEW', {count: hint.targetNewDays}),
|
||||
hint.targetEmptyLibrary && t('ADMIN_HINTS.TARGET_SUMMARY_EMPTY_LIBRARY'),
|
||||
].filter(Boolean);
|
||||
return groups.length ? groups.join(' + ') : t('ADMIN_HINTS.TARGET_ALL');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,6 +46,7 @@ const emptyForm = () => {
|
||||
export default function AdminHintsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [hints, setHints] = useState([]);
|
||||
const [automatic, setAutomatic] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -50,7 +63,9 @@ export default function AdminHintsPage() {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setHints((await response.json()).results);
|
||||
const data = await response.json();
|
||||
setHints(data.results);
|
||||
setAutomatic(data.automatic ?? []);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
console.error('Admin hints error:', err);
|
||||
@@ -91,10 +106,15 @@ export default function AdminHintsPage() {
|
||||
kind: form.kind,
|
||||
startsAt: new Date(form.startsAt).toISOString(),
|
||||
endsAt: new Date(form.endsAt).toISOString(),
|
||||
targetNoSteam: form.targetNoSteam,
|
||||
targetRole: form.targetRole || null,
|
||||
targetNewDays: form.targetNewDays ? Number(form.targetNewDays) : null,
|
||||
targetEmptyLibrary: form.targetEmptyLibrary,
|
||||
}), 'ADMIN_HINTS.CREATED', () => setForm(emptyForm()));
|
||||
};
|
||||
|
||||
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
|
||||
const tick = (field) => (event) => setForm({...form, [field]: event.target.checked});
|
||||
const formatDate = (value) => formatDateTime(value, i18n.language, {dateStyle: 'short', timeStyle: 'short'});
|
||||
|
||||
if (status === 'forbidden') {
|
||||
@@ -132,6 +152,35 @@ export default function AdminHintsPage() {
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">{t('ADMIN_HINTS.TARGET')}</legend>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="accent-primary" checked={form.targetNoSteam}
|
||||
onChange={tick('targetNoSteam')}/>
|
||||
{t('ADMIN_HINTS.TARGET_NO_STEAM')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="accent-primary" checked={form.targetEmptyLibrary}
|
||||
onChange={tick('targetEmptyLibrary')}/>
|
||||
{t('ADMIN_HINTS.TARGET_EMPTY_LIBRARY')}
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintTargetRole">{t('ADMIN_HINTS.TARGET_ROLE')}</Label>
|
||||
<select id="hintTargetRole" value={form.targetRole} onChange={change('targetRole')}
|
||||
className="h-9 w-full rounded-md border bg-transparent px-3 text-sm">
|
||||
<option value="">{t('ADMIN_HINTS.TARGET_ROLE_ALL')}</option>
|
||||
{ROLES.map((role) => <option key={role} value={role}>{t(`ROLES.${role}`)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintTargetNewDays">{t('ADMIN_HINTS.TARGET_NEW_DAYS')}</Label>
|
||||
<Input id="hintTargetNewDays" type="number" min={1} max={365} value={form.targetNewDays}
|
||||
onChange={change('targetNewDays')}/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TARGET_HELP')}</p>
|
||||
</fieldset>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintStartsAt">{t('ADMIN_HINTS.STARTS_AT')}</Label>
|
||||
@@ -157,6 +206,22 @@ export default function AdminHintsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('ADMIN_HINTS.AUTO_TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{automatic.map(({key, enabled}) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" className="accent-primary" checked={enabled} disabled={busy}
|
||||
onChange={(event) => run(() => setAutoHintEnabled(key, event.target.checked),
|
||||
'ADMIN_HINTS.AUTO_SAVED')}/>
|
||||
{t(`ADMIN_HINTS.AUTO_${key}`)}
|
||||
</label>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('ADMIN_HINTS.LIST')}</CardTitle>
|
||||
@@ -176,6 +241,7 @@ export default function AdminHintsPage() {
|
||||
{[t('ADMIN_HINTS.RANGE', {start: formatDate(hint.startsAt), end: formatDate(hint.endsAt)}),
|
||||
t(`ADMIN_HINTS.KIND_${hint.kind}`),
|
||||
t('ADMIN_HINTS.DONE_COUNT', {count: hint.doneCount}),
|
||||
targetSummary(hint, t),
|
||||
].join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,11 @@ const ACTIVE = {id: 2, textEn: 'Server update tonight', textDe: null, kind: 'DIS
|
||||
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
|
||||
|
||||
const withRoutes = (routes) => fetchRoutes({
|
||||
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED]}),
|
||||
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED],
|
||||
automatic: [{key: 'NO_STEAM', enabled: true}, {key: 'PILE_GREW', enabled: true}]}),
|
||||
'POST /api/admin/hints': json(201, {id: 3}),
|
||||
'PUT /api/admin/hints/2/end': json(204),
|
||||
'PUT /api/admin/hints/auto/PILE_GREW': json(204),
|
||||
...routes,
|
||||
});
|
||||
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
|
||||
@@ -69,6 +71,53 @@ describe('AdminHintsPage', () => {
|
||||
expect(screen.getByLabelText('ADMIN_HINTS.TEXT_EN').value).toBe('');
|
||||
});
|
||||
|
||||
it('sends the target groups, empty ones as null', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'News'}});
|
||||
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.TARGET_NO_STEAM'));
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_NEW_DAYS'), {target: {value: '7'}});
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_ROLE'), {target: {value: 'AUDITOR'}});
|
||||
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
|
||||
await waitFor(() => expect(calls('POST')).toHaveLength(1));
|
||||
expect(JSON.parse(calls('POST')[0][1].body)).toMatchObject({
|
||||
targetNoSteam: true, targetRole: 'AUDITOR', targetNewDays: 7, targetEmptyLibrary: false});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.CREATED'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'More news'}});
|
||||
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
|
||||
await waitFor(() => expect(calls('POST')).toHaveLength(2));
|
||||
expect(JSON.parse(calls('POST')[1][1].body)).toMatchObject({
|
||||
targetNoSteam: false, targetRole: null, targetNewDays: null, targetEmptyLibrary: false});
|
||||
});
|
||||
|
||||
it('shows the role labels for the role target', async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByRole('option', {name: 'ROLES.AUDITOR'})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('turns an automatic hint off', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.AUTO_PILE_GREW'));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.AUTO_SAVED'));
|
||||
const [url, init] = calls('PUT')[0];
|
||||
expect(url).toContain('/api/admin/hints/auto/PILE_GREW');
|
||||
expect(JSON.parse(init.body)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('previews bold text', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'Read **this**'}});
|
||||
|
||||
expect(document.querySelector('[role="status"] strong').textContent).toBe('this');
|
||||
});
|
||||
|
||||
it('explains an invalid time range', async () => {
|
||||
withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})});
|
||||
renderPage();
|
||||
|
||||
+7
-10
@@ -41,14 +41,10 @@ const Dashboard = () => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const runSave = useSaveResponse();
|
||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card. Waits for the saved layout in /me.
|
||||
const statGroups = [...new Set([
|
||||
...layout.map((tile) => statGroup(tile.id)).filter(Boolean),
|
||||
...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []),
|
||||
])].sort();
|
||||
// No groups would mean "all groups" to the stats endpoint.
|
||||
const hasStatGroups = statGroups.length > 0;
|
||||
const stats = useUserStats(statGroups, userData != null && hasStatGroups);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
|
||||
// saved layout in /me.
|
||||
const statGroups = [...new Set([...layout.map((tile) => statGroup(tile.id)).filter(Boolean), 'TREND'])].sort();
|
||||
const stats = useUserStats(statGroups, userData != null);
|
||||
|
||||
const shuffleSuggestion = async () => {
|
||||
setShuffling(true);
|
||||
@@ -70,7 +66,7 @@ const Dashboard = () => {
|
||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||
onComplete: () => {
|
||||
reloadLibrary();
|
||||
if (hasStatGroups) stats.reload();
|
||||
stats.reload();
|
||||
},
|
||||
});
|
||||
const forceSync = useForceSync(sync.watch);
|
||||
@@ -135,7 +131,8 @@ const Dashboard = () => {
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
<WhatsNewDialog/>
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint/>
|
||||
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
|
||||
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,8 +92,8 @@ describe('Dashboard', () => {
|
||||
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
|
||||
expect(screen.getByText('Game 2')).toBeTruthy();
|
||||
expect(screen.queryByText('Game 3')).toBeNull();
|
||||
// No pile tile, so no TREND.
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything());
|
||||
// TREND is always asked: the "pile grew" hint needs it.
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS,TREND$/), expect.anything());
|
||||
});
|
||||
|
||||
it('leaves out a stat tile whose data did not come', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useRef} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Coins} from 'lucide-react';
|
||||
import {Coins, LockKeyhole} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -17,7 +17,7 @@ import {formatDate} from '../utils/format.js';
|
||||
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
|
||||
|
||||
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
|
||||
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK'];
|
||||
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK', 'UNLOCK'];
|
||||
|
||||
export default function PointsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
@@ -58,7 +58,7 @@ export default function PointsPage() {
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (entry.kind === 'BUY') return t(`POINTS.ITEMS.${entry.ref}`);
|
||||
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
|
||||
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
|
||||
return null;
|
||||
};
|
||||
@@ -89,6 +89,7 @@ export default function PointsPage() {
|
||||
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
|
||||
const owned = wallet.owned.includes(item.code);
|
||||
const worn = wallet.equipped[slot] === item.code;
|
||||
const locked = !owned && item.track;
|
||||
return (
|
||||
<li key={item.code} className="flex items-center gap-3 py-3">
|
||||
<Preview code={item.code}/>
|
||||
@@ -99,6 +100,8 @@ export default function PointsPage() {
|
||||
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
|
||||
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
|
||||
</Button>
|
||||
) : locked ? (
|
||||
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
|
||||
@@ -133,9 +136,11 @@ export default function PointsPage() {
|
||||
{t(`POINTS.KINDS.${entry.kind}`)}
|
||||
{label && <> {label}</>}
|
||||
</span>
|
||||
{entry.points !== 0 && (
|
||||
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
||||
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -157,6 +162,14 @@ function Preview({code}) {
|
||||
if (look.Icon) {
|
||||
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
|
||||
}
|
||||
if (look.skin) {
|
||||
return (
|
||||
<span data-skin={look.skin}
|
||||
className="skin-bg bg-scroll flex size-8 shrink-0 items-center justify-center rounded-[var(--radius)] border bg-background">
|
||||
<span className="size-3 rounded-full bg-primary"/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (look.accent) {
|
||||
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
|
||||
}
|
||||
@@ -172,3 +185,22 @@ function Preview({code}) {
|
||||
}
|
||||
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
|
||||
}
|
||||
|
||||
// An unlock-only item that is not earned yet: the lock, the progress as text and as a bar. Never a price or a Buy button.
|
||||
function LockedProgress({item, progress}) {
|
||||
const {t} = useTranslation();
|
||||
const count = Math.min(progress, item.target);
|
||||
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
|
||||
return (
|
||||
<div className="flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<LockKeyhole className="size-3 shrink-0"/>
|
||||
<span title={text}>{text}</span>
|
||||
</span>
|
||||
<div role="progressbar" aria-label={t('POINTS.LOCKED')} aria-valuemin={0} aria-valuenow={count}
|
||||
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,17 @@ const wallet = (extra = {}) => ({
|
||||
{code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300},
|
||||
{code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450},
|
||||
{code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300},
|
||||
{code: 'BADGE_FINISH_10', slot: 'BADGE', price: 0, track: 'GAMES_FINISHED', target: 10},
|
||||
{code: 'BADGE_STREAK_4', slot: 'BADGE', price: 0, track: 'FINISH_WEEKS', target: 4},
|
||||
{code: 'SKIN_PASTEL', slot: 'SKIN', price: 600},
|
||||
],
|
||||
progress: {GAMES_FINISHED: 7, FINISH_WEEKS: 9},
|
||||
owned: ['BADGE_PILE_SLAYER'],
|
||||
equipped: {},
|
||||
history: [
|
||||
{kind: 'FINISHED', ref: '620', gameName: 'Portal 2', points: 50, eventAt: '2026-09-30T10:00:00Z'},
|
||||
{kind: 'MONTH', ref: '2026-09', gameName: null, points: 40, eventAt: '2026-09-30T10:00:00Z'},
|
||||
{kind: 'UNLOCK', ref: 'BADGE_PERFECT_5', gameName: null, points: 0, eventAt: '2026-09-29T10:00:00Z'},
|
||||
],
|
||||
...extra,
|
||||
});
|
||||
@@ -60,6 +65,48 @@ describe('PointsPage', () => {
|
||||
expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows a locked unlock item with its progress, a progress bar and no price or Buy button', async () => {
|
||||
await renderPage();
|
||||
|
||||
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
|
||||
expect(within(item).getByText('POINTS.PROGRESS.GAMES_FINISHED:7')).toBeTruthy();
|
||||
const bar = within(item).getByRole('progressbar');
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('7');
|
||||
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
||||
expect(within(item).queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('caps the progress at the target', async () => {
|
||||
await renderPage();
|
||||
|
||||
const item = screen.getByText('POINTS.ITEMS.BADGE_STREAK_4').closest('li');
|
||||
expect(within(item).getByText('POINTS.PROGRESS.FINISH_WEEKS:4')).toBeTruthy();
|
||||
expect(within(item).getByRole('progressbar').getAttribute('aria-valuenow')).toBe('4');
|
||||
});
|
||||
|
||||
it('lets the user equip an unlock item once it is owned', async () => {
|
||||
await renderPage({'GET /api/rewards/wallet': json(200, wallet({owned: ['BADGE_FINISH_10']}))});
|
||||
|
||||
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
|
||||
expect(within(item).getByRole('button', {name: 'POINTS.EQUIP'})).toBeTruthy();
|
||||
expect(within(item).queryByRole('progressbar')).toBeNull();
|
||||
});
|
||||
|
||||
it('previews a skin with its data-skin', async () => {
|
||||
await renderPage();
|
||||
|
||||
const card = screen.getByText('POINTS.SLOTS.SKIN').closest('[data-slot="card"]');
|
||||
expect(card.querySelector('[data-skin="pastel"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows an UNLOCK row with the item name and no number', async () => {
|
||||
await renderPage();
|
||||
|
||||
const row = screen.getByText(/POINTS.KINDS.UNLOCK/).closest('li');
|
||||
expect(row.textContent).toContain('POINTS.ITEMS.BADGE_PERFECT_5');
|
||||
expect(row.textContent).not.toMatch(/\+?0$/);
|
||||
});
|
||||
|
||||
it('holds the frame of previews that are off screen', async () => {
|
||||
const callbacks = [];
|
||||
vi.stubGlobal('IntersectionObserver', class {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, lookClass} from '../utils/rewards.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
|
||||
import PointsBadge from '../components/PointsBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/limits.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
@@ -152,7 +152,10 @@ export default function PublicProfilePage() {
|
||||
|
||||
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
|
||||
return (
|
||||
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
|
||||
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}
|
||||
data-skin={equippedSkin(equipped) ?? 'none'}>
|
||||
{/* The owner's base layer, below PageBackground (-z-10); the page's own bg-background is the viewer's. */}
|
||||
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-20 bg-background skin-bg"/>
|
||||
<PageBackground appid={style.backgroundAppid} effect={backgroundEffect(equipped)}/>
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
|
||||
|
||||
@@ -55,6 +55,23 @@ describe('PublicProfilePage', () => {
|
||||
expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('wears the skin of the owner on the wrapper with its own base layer, or resets to the plain theme', async () => {
|
||||
mockBackends({style: {accent: 'red'}, equipped: {SKIN: 'SKIN_CYBERPUNK'}});
|
||||
const {unmount} = renderPage();
|
||||
|
||||
expect(await screen.findByText('Portal 2')).toBeTruthy();
|
||||
const wrapper = screen.getByText('Lobo').closest('[data-skin]');
|
||||
expect(wrapper.dataset.skin).toBe('cyberpunk');
|
||||
expect(wrapper.querySelector('.skin-bg')).not.toBeNull();
|
||||
unmount();
|
||||
cleanup();
|
||||
|
||||
mockBackends({style: {accent: 'red'}, equipped: {}});
|
||||
renderPage();
|
||||
expect(await screen.findByText('Portal 2')).toBeTruthy();
|
||||
expect(screen.getByText('Lobo').closest('[data-skin]').dataset.skin).toBe('none');
|
||||
});
|
||||
|
||||
it('still shows the profile when the looks call fails', async () => {
|
||||
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
|
||||
renderPage();
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Skins of the points shop (utils/rewards.js LOOKS, data-skin on <html>, or on the wrapper of a public profile).
|
||||
* A skin is a full theme made of CSS variables only: every shadcn token, --radius, --logo-*, and four more:
|
||||
* --card-shadow (components/ui/card.jsx), --font-heading and --heading-case (style.css), --skin-bg (.skin-bg).
|
||||
* Skins are static: no @keyframes, no animation. System font stacks only.
|
||||
*
|
||||
* Precedence while a skin is worn (top wins):
|
||||
* badge, avatar border, banner frame, completed effect stay, and take the skin colors (--primary, --logo-*)
|
||||
* page background effect, banner effect, item 10 art stay, and paint over the skin background
|
||||
* skin colors, card style, radius, heading font, base pattern
|
||||
* premium accent, free accent, corners overridden by the skin
|
||||
* light/dark mode, "Reduce animations" always the viewer's; every skin has both modes
|
||||
*
|
||||
* Specificity is the whole precedence, because this file is imported before the accent blocks of style.css:
|
||||
* light [data-skin][data-skin="x"] (0,2,0) beats [data-accent], [data-corners]
|
||||
* dark .dark[data-skin][data-skin="x"], .dark [data-skin][data-skin="x"] (0,3,0) beats .dark[data-accent] (0,2,0)
|
||||
* The light block also matches in dark, so a dark block holds only the tokens that change with the mode. A rule that
|
||||
* uses another token (--card-shadow, --skin-bg with var(--primary)) sits in the block of that token, because custom
|
||||
* properties resolve where they are declared. The "none" reset is a plain [data-skin="none"] (0,1,0): the accent and corners of the same wrapper, later
|
||||
* in the source, still win over it. Do not add a rule with a higher specificity than these for accent or corners.
|
||||
*/
|
||||
|
||||
/* A public profile of an owner without a skin: the shadcn tokens come from the plain theme blocks of style.css. */
|
||||
[data-skin="none"] {
|
||||
--font-heading: initial;
|
||||
--heading-case: initial;
|
||||
--logo-violet: initial;
|
||||
--logo-blue: initial;
|
||||
--logo-teal: initial;
|
||||
}
|
||||
|
||||
.skin-bg {
|
||||
background-image: var(--skin-bg);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.skin-bg.bg-scroll {
|
||||
background-attachment: scroll;
|
||||
}
|
||||
|
||||
/* ---- Pastel: blush and plum, soft and round ---- */
|
||||
|
||||
[data-skin][data-skin="pastel"] {
|
||||
--background: oklch(0.97 0.02 350);
|
||||
--foreground: oklch(0.3 0.05 340);
|
||||
--card: oklch(1 0.005 350);
|
||||
--card-foreground: oklch(0.3 0.05 340);
|
||||
--popover: oklch(1 0.005 350);
|
||||
--popover-foreground: oklch(0.3 0.05 340);
|
||||
--primary: oklch(0.55 0.17 5);
|
||||
--primary-foreground: oklch(0.99 0.01 350);
|
||||
--secondary: oklch(0.94 0.03 350);
|
||||
--secondary-foreground: oklch(0.3 0.05 340);
|
||||
--muted: oklch(0.94 0.03 350);
|
||||
--muted-foreground: oklch(0.46 0.05 340);
|
||||
--accent: oklch(0.93 0.06 165);
|
||||
--accent-foreground: oklch(0.32 0.07 165);
|
||||
--border: oklch(0.88 0.04 350);
|
||||
--input: oklch(0.88 0.04 350);
|
||||
--ring: oklch(0.65 0.15 5);
|
||||
--radius: 1.25rem;
|
||||
--logo-violet: #f472b6;
|
||||
--logo-blue: #c084fc;
|
||||
--logo-teal: #5eead4;
|
||||
--card-shadow: 0 8px 28px -8px rgb(190 90 130 / 0.28);
|
||||
--font-heading: ui-rounded, "SF Pro Rounded", "Inter Variable", sans-serif;
|
||||
--heading-case: none;
|
||||
--skin-bg:
|
||||
radial-gradient(40rem 30rem at 0% 0%, rgb(249 168 212 / 0.35), transparent 70%),
|
||||
radial-gradient(36rem 28rem at 100% 100%, rgb(134 239 172 / 0.3), transparent 70%);
|
||||
}
|
||||
.dark[data-skin][data-skin="pastel"],
|
||||
.dark [data-skin][data-skin="pastel"] {
|
||||
--background: oklch(0.2 0.04 330);
|
||||
--foreground: oklch(0.95 0.02 350);
|
||||
--card: oklch(0.25 0.05 330);
|
||||
--card-foreground: oklch(0.95 0.02 350);
|
||||
--popover: oklch(0.25 0.05 330);
|
||||
--popover-foreground: oklch(0.95 0.02 350);
|
||||
--primary: oklch(0.75 0.13 5);
|
||||
--primary-foreground: oklch(0.2 0.04 330);
|
||||
--secondary: oklch(0.31 0.05 330);
|
||||
--secondary-foreground: oklch(0.95 0.02 350);
|
||||
--muted: oklch(0.31 0.05 330);
|
||||
--muted-foreground: oklch(0.76 0.04 340);
|
||||
--accent: oklch(0.35 0.07 165);
|
||||
--accent-foreground: oklch(0.93 0.05 165);
|
||||
--border: oklch(0.4 0.05 335);
|
||||
--input: oklch(0.4 0.05 335);
|
||||
--ring: oklch(0.75 0.13 5);
|
||||
--card-shadow: 0 8px 28px -8px rgb(0 0 0 / 0.5);
|
||||
--skin-bg:
|
||||
radial-gradient(40rem 30rem at 0% 0%, rgb(244 114 182 / 0.18), transparent 70%),
|
||||
radial-gradient(36rem 28rem at 100% 100%, rgb(94 234 212 / 0.14), transparent 70%);
|
||||
}
|
||||
|
||||
/* ---- Retro CRT: amber ink on paper, phosphor green on black, scanlines ---- */
|
||||
|
||||
[data-skin][data-skin="retro-crt"] {
|
||||
--background: oklch(0.95 0.03 85);
|
||||
--foreground: oklch(0.32 0.07 70);
|
||||
--card: oklch(0.975 0.02 85);
|
||||
--card-foreground: oklch(0.32 0.07 70);
|
||||
--popover: oklch(0.975 0.02 85);
|
||||
--popover-foreground: oklch(0.32 0.07 70);
|
||||
--primary: oklch(0.5 0.12 65);
|
||||
--primary-foreground: oklch(0.97 0.02 85);
|
||||
--secondary: oklch(0.91 0.04 85);
|
||||
--secondary-foreground: oklch(0.32 0.07 70);
|
||||
--muted: oklch(0.91 0.04 85);
|
||||
--muted-foreground: oklch(0.46 0.07 70);
|
||||
--accent: oklch(0.88 0.08 80);
|
||||
--accent-foreground: oklch(0.3 0.07 70);
|
||||
--border: oklch(0.78 0.07 75);
|
||||
--input: oklch(0.78 0.07 75);
|
||||
--ring: oklch(0.6 0.14 70);
|
||||
--radius: 0.25rem;
|
||||
--logo-violet: #d97706;
|
||||
--logo-blue: #b45309;
|
||||
--logo-teal: #ca8a04;
|
||||
--card-shadow: inset 0 0 18px rgb(180 110 20 / 0.18);
|
||||
--font-heading: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
|
||||
--heading-case: uppercase;
|
||||
--skin-bg:
|
||||
repeating-linear-gradient(0deg, rgb(120 70 10 / 0.07) 0 1px, transparent 1px 3px),
|
||||
radial-gradient(ellipse at center, transparent 55%, rgb(120 70 10 / 0.18));
|
||||
}
|
||||
.dark[data-skin][data-skin="retro-crt"],
|
||||
.dark [data-skin][data-skin="retro-crt"] {
|
||||
--background: oklch(0.15 0.02 150);
|
||||
--foreground: oklch(0.85 0.19 145);
|
||||
--card: oklch(0.19 0.03 150);
|
||||
--card-foreground: oklch(0.85 0.19 145);
|
||||
--popover: oklch(0.19 0.03 150);
|
||||
--popover-foreground: oklch(0.85 0.19 145);
|
||||
--primary: oklch(0.82 0.2 145);
|
||||
--primary-foreground: oklch(0.15 0.03 150);
|
||||
--secondary: oklch(0.25 0.05 150);
|
||||
--secondary-foreground: oklch(0.85 0.19 145);
|
||||
--muted: oklch(0.25 0.05 150);
|
||||
--muted-foreground: oklch(0.7 0.14 145);
|
||||
--accent: oklch(0.3 0.08 150);
|
||||
--accent-foreground: oklch(0.9 0.15 145);
|
||||
--border: oklch(0.45 0.12 145);
|
||||
--input: oklch(0.45 0.12 145);
|
||||
--ring: oklch(0.82 0.2 145);
|
||||
--logo-violet: #4ade80;
|
||||
--logo-blue: #86efac;
|
||||
--logo-teal: #22c55e;
|
||||
--card-shadow: inset 0 0 18px rgb(74 222 128 / 0.14);
|
||||
--skin-bg:
|
||||
repeating-linear-gradient(0deg, rgb(0 0 0 / 0.28) 0 1px, transparent 1px 3px),
|
||||
radial-gradient(ellipse at center, transparent 50%, rgb(0 0 0 / 0.5));
|
||||
}
|
||||
|
||||
/* ---- Cyberpunk: neon yellow and cyan on violet black, a faint grid ---- */
|
||||
|
||||
[data-skin][data-skin="cyberpunk"] {
|
||||
--background: oklch(0.97 0.05 100);
|
||||
--foreground: oklch(0.22 0.06 300);
|
||||
--card: oklch(0.99 0.03 100);
|
||||
--card-foreground: oklch(0.22 0.06 300);
|
||||
--popover: oklch(0.99 0.03 100);
|
||||
--popover-foreground: oklch(0.22 0.06 300);
|
||||
--primary: oklch(0.5 0.24 350);
|
||||
--primary-foreground: oklch(0.99 0.01 100);
|
||||
--secondary: oklch(0.93 0.06 100);
|
||||
--secondary-foreground: oklch(0.22 0.06 300);
|
||||
--muted: oklch(0.93 0.06 100);
|
||||
--muted-foreground: oklch(0.42 0.07 300);
|
||||
--accent: oklch(0.85 0.1 200);
|
||||
--accent-foreground: oklch(0.25 0.07 220);
|
||||
--border: oklch(0.6 0.12 200);
|
||||
--input: oklch(0.6 0.12 200);
|
||||
--ring: oklch(0.6 0.2 350);
|
||||
--radius: 0.125rem;
|
||||
--logo-violet: #db2777;
|
||||
--logo-blue: #06b6d4;
|
||||
--logo-teal: #eab308;
|
||||
--card-shadow: 0 0 0 1px oklch(0.6 0.12 200), 0 0 14px -2px oklch(0.7 0.12 200 / 0.5);
|
||||
--font-heading: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
|
||||
--heading-case: uppercase;
|
||||
--skin-bg:
|
||||
repeating-linear-gradient(0deg, rgb(6 182 212 / 0.12) 0 1px, transparent 1px 40px),
|
||||
repeating-linear-gradient(90deg, rgb(6 182 212 / 0.12) 0 1px, transparent 1px 40px);
|
||||
}
|
||||
.dark[data-skin][data-skin="cyberpunk"],
|
||||
.dark [data-skin][data-skin="cyberpunk"] {
|
||||
--background: oklch(0.15 0.04 300);
|
||||
--foreground: oklch(0.95 0.03 100);
|
||||
--card: oklch(0.2 0.05 300);
|
||||
--card-foreground: oklch(0.95 0.03 100);
|
||||
--popover: oklch(0.2 0.05 300);
|
||||
--popover-foreground: oklch(0.95 0.03 100);
|
||||
--primary: oklch(0.92 0.18 105);
|
||||
--primary-foreground: oklch(0.18 0.04 300);
|
||||
--secondary: oklch(0.27 0.06 300);
|
||||
--secondary-foreground: oklch(0.95 0.03 100);
|
||||
--muted: oklch(0.27 0.06 300);
|
||||
--muted-foreground: oklch(0.78 0.08 200);
|
||||
--accent: oklch(0.8 0.13 200);
|
||||
--accent-foreground: oklch(0.18 0.04 220);
|
||||
--border: oklch(0.6 0.12 200);
|
||||
--input: oklch(0.6 0.12 200);
|
||||
--ring: oklch(0.8 0.13 200);
|
||||
--logo-violet: #facc15;
|
||||
--logo-blue: #22d3ee;
|
||||
--logo-teal: #e879f9;
|
||||
--card-shadow: 0 0 0 1px oklch(0.6 0.12 200), 0 0 18px -2px oklch(0.8 0.13 200 / 0.45);
|
||||
--skin-bg:
|
||||
repeating-linear-gradient(0deg, rgb(34 211 238 / 0.07) 0 1px, transparent 1px 40px),
|
||||
repeating-linear-gradient(90deg, rgb(34 211 238 / 0.07) 0 1px, transparent 1px 40px);
|
||||
}
|
||||
|
||||
/* ---- Steam: the dark blue of a game store (colors only, no logo or art) ---- */
|
||||
|
||||
[data-skin][data-skin="steam"] {
|
||||
--background: #e5edf3;
|
||||
--foreground: #1b2838;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1b2838;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1b2838;
|
||||
--primary: #1a6fa8;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #d3e0ea;
|
||||
--secondary-foreground: #1b2838;
|
||||
--muted: #d3e0ea;
|
||||
--muted-foreground: #4a5e70;
|
||||
--accent: #cfe3f2;
|
||||
--accent-foreground: #124e78;
|
||||
--border: #b5c7d6;
|
||||
--input: #b5c7d6;
|
||||
--ring: #66c0f4;
|
||||
--radius: 0.125rem;
|
||||
--logo-violet: #1a6fa8;
|
||||
--logo-blue: #66c0f4;
|
||||
--logo-teal: #2a475e;
|
||||
--card-shadow: 0 2px 6px rgb(27 40 56 / 0.25);
|
||||
--font-heading: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
--heading-case: uppercase;
|
||||
--skin-bg: radial-gradient(60rem 28rem at 50% 0%, rgb(42 71 94 / 0.22), transparent 70%);
|
||||
}
|
||||
.dark[data-skin][data-skin="steam"],
|
||||
.dark [data-skin][data-skin="steam"] {
|
||||
--background: #1b2838;
|
||||
--foreground: #c7d5e0;
|
||||
--card: #16202d;
|
||||
--card-foreground: #c7d5e0;
|
||||
--popover: #16202d;
|
||||
--popover-foreground: #c7d5e0;
|
||||
--primary: #66c0f4;
|
||||
--primary-foreground: #0e1822;
|
||||
--secondary: #2a475e;
|
||||
--secondary-foreground: #c7d5e0;
|
||||
--muted: #2a475e;
|
||||
--muted-foreground: #a9bccb;
|
||||
--accent: #2a475e;
|
||||
--accent-foreground: #8fd0f7;
|
||||
--border: #3d5a73;
|
||||
--input: #3d5a73;
|
||||
--ring: #66c0f4;
|
||||
--logo-violet: #66c0f4;
|
||||
--logo-blue: #c7d5e0;
|
||||
--logo-teal: #4b8fb8;
|
||||
--card-shadow: 0 4px 12px rgb(0 0 0 / 0.5);
|
||||
--skin-bg: radial-gradient(60rem 28rem at 50% 0%, #2a475e, transparent 70%);
|
||||
}
|
||||
+13
-2
@@ -1,12 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./effects.css";
|
||||
@import "./skins.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* shadcn/ui theme tokens: zinc surfaces, violet accent. */
|
||||
:root {
|
||||
:root,
|
||||
[data-skin="none"] {
|
||||
--radius: 0.75rem;
|
||||
--background: oklch(0.985 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@@ -26,9 +28,12 @@
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.606 0.25 292.717);
|
||||
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--skin-bg: none;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.dark,
|
||||
.dark [data-skin="none"] {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
@@ -277,6 +282,12 @@
|
||||
option {
|
||||
@apply bg-popover text-popover-foreground;
|
||||
}
|
||||
|
||||
/* Skins (skins.css) set both; without a skin they change nothing. */
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-heading, inherit);
|
||||
text-transform: var(--heading-case, none);
|
||||
}
|
||||
}
|
||||
|
||||
/* Radix menus and selects lock the page scroll and add a right margin as wide as the scrollbar, measured as
|
||||
|
||||
@@ -39,9 +39,12 @@ export const readCachedAppearance = () => {
|
||||
|
||||
export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance));
|
||||
|
||||
// accentOverride: a premium accent worn from the shop (Core owns it), which wins over the saved one.
|
||||
export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
|
||||
root.dataset.accent = accentOverride ?? appearance.accent;
|
||||
// accent: a premium accent worn from the shop (Core owns it), which wins over the saved one. skin: the skin worn;
|
||||
// skins.css lets it win over accent and corners.
|
||||
export const applyAppearance = (appearance, {accent = null, skin = null} = {}, root = document.documentElement) => {
|
||||
root.dataset.accent = accent ?? appearance.accent;
|
||||
if (skin) root.dataset.skin = skin;
|
||||
else delete root.dataset.skin;
|
||||
root.dataset.corners = appearance.corners;
|
||||
root.toggleAttribute('data-reduce-motion', appearance.reduceMotion === true);
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('applyAppearance', () => {
|
||||
it('sets the presets as data attributes for the CSS', () => {
|
||||
const root = document.createElement('html');
|
||||
|
||||
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, null, root);
|
||||
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, {}, root);
|
||||
|
||||
expect(root.dataset.accent).toBe('orange');
|
||||
expect(root.dataset.corners).toBe('round');
|
||||
@@ -54,10 +54,21 @@ describe('applyAppearance', () => {
|
||||
it('lets a worn premium accent win over the saved one', () => {
|
||||
const root = document.createElement('html');
|
||||
|
||||
applyAppearance({accent: 'orange', corners: 'round'}, 'gold', root);
|
||||
applyAppearance({accent: 'orange', corners: 'round'}, {accent: 'gold'}, root);
|
||||
|
||||
expect(root.dataset.accent).toBe('gold');
|
||||
});
|
||||
|
||||
it('sets the worn skin and removes it again', () => {
|
||||
const root = document.createElement('html');
|
||||
|
||||
applyAppearance({accent: 'orange', corners: 'round'}, {accent: 'gold', skin: 'cyberpunk'}, root);
|
||||
expect(root.dataset.skin).toBe('cyberpunk');
|
||||
expect(root.dataset.accent).toBe('gold');
|
||||
|
||||
applyAppearance({accent: 'orange', corners: 'round'}, {}, root);
|
||||
expect(root.dataset.skin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce motion', () => {
|
||||
@@ -71,10 +82,10 @@ describe('reduce motion', () => {
|
||||
it('is set on the root by applyAppearance and removed again', () => {
|
||||
const root = document.createElement('html');
|
||||
|
||||
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: true}, null, root);
|
||||
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: true}, {}, root);
|
||||
expect(root.hasAttribute('data-reduce-motion')).toBe(true);
|
||||
|
||||
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, null, root);
|
||||
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, {}, root);
|
||||
expect(root.hasAttribute('data-reduce-motion')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
+41
-2
@@ -3,8 +3,9 @@ import {apiFetch} from './api.js';
|
||||
const AUTH = () => import.meta.env.VITE_API_URL;
|
||||
|
||||
/**
|
||||
* GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt}]}, the running hints the user has not
|
||||
* closed for good, newest first.
|
||||
* GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt, emptyLibraryOnly}], automatic: [keys]}.
|
||||
* results: the running hints for this user that are not closed for good, newest first (emptyLibraryOnly: show only
|
||||
* with an empty library, checked here). automatic: the automatic hints that are on and not hidden for this user.
|
||||
*/
|
||||
export const fetchHints = () => apiFetch(`${AUTH()}/api/hints`);
|
||||
|
||||
@@ -31,3 +32,41 @@ export const createHint = (hint) =>
|
||||
export const endHint = (hintId) => apiFetch(`${AUTH()}/api/admin/hints/${hintId}/end`, {method: 'PUT'});
|
||||
|
||||
export const hintText = (hint, language) => (language?.startsWith('de') && hint.textDe ? hint.textDe : hint.textEn);
|
||||
|
||||
/**
|
||||
* The automatic hints in the order they show.
|
||||
*/
|
||||
export const AUTO_HINTS = ['NO_STEAM', 'EMPTY_LIBRARY', 'LINK_QUEUE', 'PILE_GREW'];
|
||||
|
||||
/**
|
||||
* 204 or 404 HINT_NOT_FOUND: hide the automatic hint for 7 days.
|
||||
*/
|
||||
export const snoozeAutoHint = (key) => apiFetch(`${AUTH()}/api/hints/auto/${key}/snooze`, {method: 'PUT'});
|
||||
|
||||
/**
|
||||
* 204 or 404 HINT_NOT_FOUND: hide the automatic hint for good.
|
||||
*/
|
||||
export const hideAutoHint = (key) => apiFetch(`${AUTH()}/api/hints/auto/${key}/done`, {method: 'PUT'});
|
||||
|
||||
/**
|
||||
* PLATFORM_ADMIN only: 204, or 404 HINT_NOT_FOUND.
|
||||
*/
|
||||
export const setAutoHintEnabled = (key, enabled) =>
|
||||
apiFetch(`${AUTH()}/api/admin/hints/auto/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({enabled}),
|
||||
});
|
||||
|
||||
/**
|
||||
* How much the pile grew since the end of last month (else since the first day of this month), from the TREND group of the stats. Uses Core's
|
||||
* dates, not the browser clock.
|
||||
*/
|
||||
export const pileGrowth = (trend) => {
|
||||
const days = trend?.days ?? [];
|
||||
const last = days.at(-1);
|
||||
const month = last?.day.slice(0, 7);
|
||||
// The snapshot of a day is written at its end, so only last month's last row excludes the first day's additions.
|
||||
const first = days.findLast((d) => d.day.slice(0, 7) < month) ?? days.find((d) => d.day.slice(0, 7) === month);
|
||||
return last && first !== last ? last.backlogGames - first.backlogGames : 0;
|
||||
};
|
||||
+22
-2
@@ -1,11 +1,11 @@
|
||||
import {Swords, Trophy} from 'lucide-react';
|
||||
import {Flame, Flag, Gem, Swords, TrendingUp, Trophy} from 'lucide-react';
|
||||
import {apiFetch, request} from './api.js';
|
||||
import {storage} from './storage.js';
|
||||
|
||||
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
|
||||
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND'];
|
||||
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND', 'SKIN'];
|
||||
|
||||
// The shop catalog of Core (ShopItem) as looks: only these classes are ever applied, never a string from the server.
|
||||
// An unknown code is skipped.
|
||||
@@ -40,11 +40,31 @@ export const LOOKS = {
|
||||
BN_GRID: {effect: 'grid'},
|
||||
BN_WAVES: {effect: 'waves'},
|
||||
BN_BOKEH: {effect: 'bokeh'},
|
||||
// Unlock-only items (Core: earned by progress, never sold).
|
||||
BADGE_FINISH_10: {Icon: Flag, className: 'bg-emerald-500/15 text-emerald-500'},
|
||||
BORDER_FINISH_25: {className: 'ring-4 ring-slate-300 ring-offset-2 ring-offset-background'},
|
||||
FRAME_FINISH_50: {className: 'ring-2 ring-emerald-400 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-emerald-400/50'},
|
||||
BADGE_PERFECT_5: {Icon: Gem, className: 'bg-cyan-500/15 text-cyan-500'},
|
||||
FX_PLATINUM: {cover: 'fx fx-cover-platinum outline outline-2 -outline-offset-2 outline-slate-200', detail: 'fx-detail-platinum'},
|
||||
BADGE_STREAK_4: {Icon: Flame, className: 'bg-orange-500/15 text-orange-500'},
|
||||
BORDER_STREAK_12: {className: 'ring-4 ring-orange-500 shadow-[0_0_14px] shadow-orange-500/60'},
|
||||
FRAME_PILE_DOWN_3: {className: 'ring-2 ring-teal-400 ring-offset-2 ring-offset-background'},
|
||||
BADGE_PLAY_MORE_1: {Icon: TrendingUp, className: 'bg-sky-500/15 text-sky-500'},
|
||||
BORDER_PLAY_MORE_3: {className: 'ring-4 ring-sky-400 ring-offset-2 ring-offset-background'},
|
||||
BN_EQUALIZER: {effect: 'equalizer'},
|
||||
// Skins (skins.css): full themes, set as data-skin.
|
||||
SKIN_PASTEL: {skin: 'pastel'},
|
||||
SKIN_RETRO_CRT: {skin: 'retro-crt'},
|
||||
SKIN_CYBERPUNK: {skin: 'cyberpunk'},
|
||||
SKIN_STEAM: {skin: 'steam'},
|
||||
};
|
||||
|
||||
/** The premium accent worn, or null (then the appearance accent applies). */
|
||||
export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? null;
|
||||
|
||||
/** The skin worn (a data-skin value of skins.css), or null. */
|
||||
export const equippedSkin = (equipped) => LOOKS[equipped?.SKIN]?.skin ?? null;
|
||||
|
||||
/** The classes of the item worn in a slot, or undefined. */
|
||||
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, LOOKS} from './rewards.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js';
|
||||
|
||||
describe('effects worn', () => {
|
||||
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
|
||||
@@ -24,3 +24,12 @@ describe('effects worn', () => {
|
||||
expect(LOOKS.BG_GRID.effect).toBe(LOOKS.BN_GRID.effect);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equippedSkin', () => {
|
||||
it('gives the data-skin value of a known skin and null otherwise', () => {
|
||||
expect(equippedSkin({SKIN: 'SKIN_RETRO_CRT'})).toBe('retro-crt');
|
||||
expect(equippedSkin({SKIN: 'SKIN_FUTURE'})).toBeNull();
|
||||
expect(equippedSkin({})).toBeNull();
|
||||
expect(equippedSkin(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user