Public Access
127 lines
6.9 KiB
React
127 lines
6.9 KiB
React
import {useState} from 'react';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {ArrowDown, ArrowUp, Check, EyeOff, Loader2, Plus, RotateCcw, X} from 'lucide-react';
|
|
|
|
import {Button} from '@/components/ui/button';
|
|
import {Card} from '@/components/ui/card';
|
|
import {cn} from '@/lib/utils';
|
|
import {SIZES, spanClass} from '../utils/tiles.js';
|
|
|
|
/**
|
|
* The tiles of a page in their order, in a 12 column grid (one column on a phone). In edit mode each tile gets
|
|
* move, size and hide buttons, and the hidden tiles can be added again; nothing is saved before "Done".
|
|
*
|
|
* @param tiles the tile table (utils/tiles.js)
|
|
* @param titles tile id -> i18n key of its name in the edit mode
|
|
* @param renderTile (id, size) => the tile, or null when it has nothing to show (then it is left out, and in edit
|
|
* mode it shows a placeholder so it can still be moved)
|
|
* @param defaultLayout offered as "Reset"; none: no reset
|
|
* @param onSave (layout) => promise; the page ends the edit mode when the save worked
|
|
*/
|
|
export default function TileGrid({tiles, titles, layout, renderTile, editing, defaultLayout, onSave, onCancel}) {
|
|
if (!editing) {
|
|
return (
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
|
|
{layout.map((tile) => {
|
|
const content = renderTile(tile.id, tile.size);
|
|
return content && (
|
|
// *:h-full: the card fills the grid row, as it did as a direct grid item.
|
|
<div key={tile.id} data-tile={tile.id} className={cn('min-w-0 *:h-full', spanClass(tiles, tile))}>{content}</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <TileEditor tiles={tiles} titles={titles} layout={layout} renderTile={renderTile} defaultLayout={defaultLayout}
|
|
onSave={onSave} onCancel={onCancel}/>;
|
|
}
|
|
|
|
// Mounted per edit session, so every session starts from the saved layout.
|
|
function TileEditor({tiles, titles, layout, renderTile, defaultLayout, onSave, onCancel}) {
|
|
const {t} = useTranslation();
|
|
const [shown, setShown] = useState(layout);
|
|
const [saving, setSaving] = useState(false);
|
|
const hidden = Object.keys(tiles).filter((id) => !shown.some((tile) => tile.id === id));
|
|
const move = (index, by) => {
|
|
const next = [...shown];
|
|
[next[index], next[index + by]] = [next[index + by], next[index]];
|
|
setShown(next);
|
|
};
|
|
const save = async () => {
|
|
setSaving(true);
|
|
await onSave(shown);
|
|
setSaving(false);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-dashed border-primary/50 px-4 py-3">
|
|
<p className="text-sm text-muted-foreground">{t('TILES.EDIT_HINT')}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{defaultLayout && (
|
|
<Button variant="ghost" size="sm" disabled={saving} onClick={() => setShown(defaultLayout)}>
|
|
<RotateCcw/>{t('TILES.RESET')}
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" size="sm" disabled={saving} onClick={onCancel}><X/>{t('TILES.CANCEL')}</Button>
|
|
<Button size="sm" disabled={saving} onClick={save}>
|
|
{saving ? <Loader2 className="animate-spin"/> : <Check/>}{t('TILES.DONE')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
|
|
{shown.map((tile, index) => {
|
|
const name = t(titles[tile.id]);
|
|
const sizes = SIZES.filter((size) => tiles[tile.id][size]);
|
|
return (
|
|
<div key={tile.id} data-tile={tile.id} className={cn('min-w-0 space-y-2', spanClass(tiles, tile))}>
|
|
<div className="flex flex-wrap items-center gap-1 rounded-md bg-muted px-2 py-1">
|
|
<span className="mr-auto truncate text-sm font-medium">{name}</span>
|
|
<Button variant="ghost" size="icon" className="size-7" disabled={index === 0}
|
|
aria-label={t('TILES.MOVE_UP', {name})} onClick={() => move(index, -1)}><ArrowUp/></Button>
|
|
<Button variant="ghost" size="icon" className="size-7" disabled={index === shown.length - 1}
|
|
aria-label={t('TILES.MOVE_DOWN', {name})} onClick={() => move(index, 1)}><ArrowDown/></Button>
|
|
{sizes.length > 1 && (
|
|
<div role="group" aria-label={t('TILES.SIZE', {name})} className="flex">
|
|
{sizes.map((size) => (
|
|
<Button key={size} variant={tile.size === size ? 'default' : 'ghost'} size="sm"
|
|
className="h-7 px-2" aria-pressed={tile.size === size}
|
|
aria-label={t(`TILES.SIZE_${size}`)}
|
|
onClick={() => setShown(shown.map((other) => other.id === tile.id ? {...other, size} : other))}>
|
|
{size}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Button variant="ghost" size="icon" className="size-7" aria-label={t('TILES.HIDE', {name})}
|
|
onClick={() => setShown(shown.filter((other) => other.id !== tile.id))}><EyeOff/></Button>
|
|
</div>
|
|
{/* inert: a click on a game in the tile must not leave the edit mode. */}
|
|
<div inert className="opacity-80">
|
|
{renderTile(tile.id, tile.size) || (
|
|
<Card className="items-center p-6 text-sm text-muted-foreground">{t('TILES.NO_DATA')}</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{hidden.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">{t('TILES.HIDDEN')}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{hidden.map((id) => (
|
|
<Button key={id} variant="outline" size="sm" onClick={() => setShown([...shown, {id, size: 'M'}])}>
|
|
<Plus/>{t(titles[id])}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|