Files
ToBePlayed-Frontend/src/components/TileGrid.jsx
T
lobo aa9e83e0cb
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 28s
feat(tiles): let users arrange the dashboard and profile tiles
2026-09-28 15:03:16 +02:00

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>
);
}