Public Access
feat(tiles): let users arrange the dashboard and profile tiles
This commit is contained in:
1 parent
b55fcfec92
commit
aa9e83e0cb
24 files changed
+792
-426
No files matched your search
@@ -0,0 +1,126 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user