Public Access
Synthwave, fireflies, snow, sakura, nebula: CSS/SVG. Matrix and constellation: canvas. Existing engines gain depth, color, glow, and parallax (mouse and device tilt, opt-in on iOS)
121 lines
6.5 KiB
React
121 lines
6.5 KiB
React
import {useState} from 'react';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {ImageOff, Palette, RotateCcw} from 'lucide-react';
|
|
|
|
import {Button} from '@/components/ui/button';
|
|
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';
|
|
import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js';
|
|
|
|
export default function AppearanceCard() {
|
|
const {t} = useTranslation();
|
|
const {appearance, equipped} = usePreferences();
|
|
const saveAppearance = useSaveAppearance();
|
|
const [tilt, setTilt] = useState(tiltAllowed);
|
|
const [tiltRefused, setTiltRefused] = useState(false);
|
|
|
|
// requestTilt() starts inside the tap, as iOS demands.
|
|
const changeTilt = (on) => {
|
|
if (!on) {
|
|
disableTilt();
|
|
setTilt(false);
|
|
return;
|
|
}
|
|
requestTilt().then((granted) => {
|
|
setTilt(granted);
|
|
setTiltRefused(!granted);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
|
|
</CardHeader>
|
|
<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>
|
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.WIDE_LAYOUT_HINT')}</p>
|
|
</div>
|
|
<Switch id="wideLayout" checked={appearance.wideLayout}
|
|
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reduceMotion">{t('SETTINGS.REDUCE_MOTION')}</Label>
|
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.REDUCE_MOTION_HINT')}</p>
|
|
</div>
|
|
<Switch id="reduceMotion" checked={appearance.reduceMotion}
|
|
onCheckedChange={(reduceMotion) => saveAppearance({reduceMotion})}/>
|
|
</div>
|
|
{tiltNeedsPermission() && (
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="tilt">{t('SETTINGS.TILT')}</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(tiltRefused ? 'SETTINGS.TILT_DENIED' : 'SETTINGS.TILT_HINT')}
|
|
</p>
|
|
</div>
|
|
<Switch id="tilt" checked={tilt} disabled={appearance.reduceMotion}
|
|
onCheckedChange={changeTilt}/>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
|
|
{!appearance.backgroundAppid && (
|
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.BACKGROUND_HINT')}</p>
|
|
)}
|
|
</div>
|
|
{appearance.backgroundAppid && (
|
|
<div className="flex items-center gap-2">
|
|
<img src={coverUrl(appearance.backgroundAppid)} alt=""
|
|
onError={(event) => { event.currentTarget.hidden = true; }}
|
|
className="h-10 w-20 rounded-md object-cover"/>
|
|
<Button type="button" variant="outline" size="sm"
|
|
aria-describedby="background-label"
|
|
onClick={() => saveAppearance({backgroundAppid: null})}>
|
|
<ImageOff/>{t('SETTINGS.BACKGROUND_REMOVE')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label id="banner-label" htmlFor="showBanner">{t('SETTINGS.BANNER')}</Label>
|
|
{appearance.showBanner && !appearance.bannerAppid && (
|
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.BANNER_HINT')}</p>
|
|
)}
|
|
</div>
|
|
<Switch id="showBanner" checked={appearance.showBanner}
|
|
onCheckedChange={(showBanner) => saveAppearance({showBanner})}/>
|
|
</div>
|
|
{appearance.showBanner && appearance.bannerAppid && (
|
|
<div className="flex items-center justify-end gap-2">
|
|
<img src={coverUrl(appearance.bannerAppid)} alt=""
|
|
onError={(event) => { event.currentTarget.hidden = true; }}
|
|
className="h-10 w-20 rounded-md object-cover"/>
|
|
<Button type="button" variant="outline" size="sm"
|
|
aria-describedby="banner-label"
|
|
onClick={() => saveAppearance({bannerAppid: null})}>
|
|
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|