mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 14:14:07 +02:00
Integrate folders into desktop, mobile home screen, and settings
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
32fbada1cf
commit
0cec461ea1
@@ -13,6 +13,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { desktopApps } from '@/os/registry';
|
||||
import { useIconLayout } from '@/os/useIconLayout';
|
||||
import { useFolders } from '@/os/useFolders';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { npubOf } from '@/lib/nostrUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -59,6 +60,7 @@ function IconLayoutSection() {
|
||||
// Reset uses a deterministic, measurement-free registry order. The desktop
|
||||
// clamps it to its actual surface on render; mobile reflows into its columns.
|
||||
const { reset } = useIconLayout(appIds, { columns: 8, rows: 16 });
|
||||
const { resetFolders } = useFolders(appIds);
|
||||
|
||||
const resetAndToast = (profile: 'desktop' | 'mobile' | 'both') => {
|
||||
reset(profile);
|
||||
@@ -68,12 +70,21 @@ function IconLayoutSection() {
|
||||
return (
|
||||
<Section
|
||||
title="Home screen layout"
|
||||
description="Arrange icons by dragging them. On a keyboard, press Space to pick up an icon, use the arrow keys to move it, then press Enter to drop."
|
||||
description="Arrange icons by dragging them. On a keyboard, press Space to pick up an icon, use the arrow keys to move it, then press Enter to drop. Folders group apps together; deleting a folder returns its apps here."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => resetAndToast('desktop')}>Reset desktop</Button>
|
||||
<Button variant="outline" onClick={() => resetAndToast('mobile')}>Reset mobile</Button>
|
||||
<Button variant="outline" onClick={() => resetAndToast('both')}>Reset both</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetFolders();
|
||||
toast({ title: 'Folders deleted' });
|
||||
}}
|
||||
>
|
||||
Delete all folders
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -4,20 +4,40 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { DesktopIcon } from './DesktopIcon';
|
||||
import { FolderVisual } from './FolderIcon';
|
||||
import { FolderDialog } from './FolderDialog';
|
||||
import { FolderWindow } from './FolderWindow';
|
||||
import { WindowLayer } from './WindowLayer';
|
||||
import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { desktopApps } from '@/os/registry';
|
||||
import { MENUBAR_HEIGHT } from '@/os/layout';
|
||||
import { swapDesktopSlots, type DesktopSlot, type GridGeometry } from '@/os/iconLayout';
|
||||
import { useIconLayout } from '@/os/useIconLayout';
|
||||
import { useFolders } from '@/os/useFolders';
|
||||
import { MAX_FOLDERS, type Folder } from '@/os/folders';
|
||||
|
||||
const CELL_WIDTH = 96;
|
||||
const CELL_HEIGHT = 92;
|
||||
const SURFACE_PADDING = 12;
|
||||
const DRAG_THRESHOLD = 6;
|
||||
/** Prefix that distinguishes folder entries from app entries on the grid. */
|
||||
export const FOLDER_ID_PREFIX = 'folder:';
|
||||
|
||||
function geometryFor(width: number, height: number): GridGeometry {
|
||||
return {
|
||||
@@ -26,10 +46,19 @@ function geometryFor(width: number, height: number): GridGeometry {
|
||||
};
|
||||
}
|
||||
|
||||
interface FolderDialogState {
|
||||
open: boolean;
|
||||
/** Set when renaming an existing folder. */
|
||||
folder?: Folder;
|
||||
/** Set when creating a folder around an app ("New folder with X"). */
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop surface: dot-grid wallpaper, the app icons, and the layer the
|
||||
* windows are positioned inside. Window coordinates are relative to this box,
|
||||
* which is why it sits below the menu bar rather than at the viewport origin.
|
||||
* The desktop surface: dot-grid wallpaper, the app icons and folders, and the
|
||||
* layer the windows are positioned inside. Window coordinates are relative to
|
||||
* this box, which is why it sits below the menu bar rather than at the
|
||||
* viewport origin.
|
||||
*/
|
||||
export function Desktop() {
|
||||
const { openApp, windows, minimizeAll, closeAll } = useWindowManager();
|
||||
@@ -37,14 +66,74 @@ export function Desktop() {
|
||||
const [surfaceSize, setSurfaceSize] = useState(() => ({ width: window.innerWidth, height: window.innerHeight - MENUBAR_HEIGHT }));
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [candidate, setCandidate] = useState<{ col: number; row: number } | null>(null);
|
||||
const [dropFolder, setDropFolder] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [pickedLayout, setPickedLayout] = useState<DesktopSlot[] | null>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const [folderDialog, setFolderDialog] = useState<FolderDialogState>({ open: false });
|
||||
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<Folder | null>(null);
|
||||
const pointerStart = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null);
|
||||
const apps = desktopApps();
|
||||
const geometry = useMemo(() => geometryFor(surfaceSize.width, surfaceSize.height), [surfaceSize]);
|
||||
const { layout, setDesktop, reset } = useIconLayout(apps.map((app) => app.id), geometry);
|
||||
const appIds = apps.map((app) => app.id);
|
||||
const { folderState, createFolder, renameFolder, removeFolder, setAppFolder } = useFolders(appIds);
|
||||
const { layout, setDesktop, reset } = useIconLayout(appIds, geometry, folderState);
|
||||
const slots = layout.desktop;
|
||||
const { folders, membership } = folderState;
|
||||
|
||||
const topLevelApps = useMemo(() => apps.filter((app) => !membership[app.id]), [apps, membership]);
|
||||
const folderContents = useMemo(() => {
|
||||
const map = new Map<string, typeof apps>();
|
||||
for (const folder of folders) map.set(folder.id, []);
|
||||
for (const app of apps) {
|
||||
const folderId = membership[app.id];
|
||||
if (folderId) map.get(folderId)?.push(app);
|
||||
}
|
||||
return map;
|
||||
}, [apps, folders, membership]);
|
||||
|
||||
const folderTitle = useCallback(
|
||||
(id: string | null | undefined) => folders.find((folder) => folder.id === id)?.name,
|
||||
[folders],
|
||||
);
|
||||
|
||||
const moveToFolder = useCallback((appId: string, folderId: string) => {
|
||||
setAppFolder(appId, folderId);
|
||||
setAnnouncement(`${appId} moved into folder ${folderTitle(folderId) ?? folderId}.`);
|
||||
}, [setAppFolder, folderTitle]);
|
||||
|
||||
const removeFromFolder = useCallback((appId: string) => {
|
||||
setAppFolder(appId, null);
|
||||
setAnnouncement(`${appId} moved out of ${folderTitle(membership[appId]) ?? 'its folder'} to the desktop.`);
|
||||
}, [setAppFolder, folderTitle, membership]);
|
||||
|
||||
const deleteFolder = useCallback((folder: Folder) => {
|
||||
removeFolder(folder.id);
|
||||
setOpenFolderId((current) => (current === folder.id ? null : current));
|
||||
setConfirmDelete(null);
|
||||
setAnnouncement(`Folder ${folder.name} deleted. Its apps moved back to the desktop.`);
|
||||
}, [removeFolder]);
|
||||
|
||||
const submitFolderDialog = useCallback((name: string) => {
|
||||
if (folderDialog.folder) {
|
||||
renameFolder(folderDialog.folder.id, name);
|
||||
setAnnouncement(`Folder renamed to ${name}.`);
|
||||
} else {
|
||||
const id = createFolder(name);
|
||||
if (folderDialog.appId) {
|
||||
setAppFolder(folderDialog.appId, id);
|
||||
setAnnouncement(`Folder ${name} created with ${folderDialog.appId} inside.`);
|
||||
} else {
|
||||
setAnnouncement(`Folder ${name} created.`);
|
||||
}
|
||||
}
|
||||
}, [folderDialog, createFolder, renameFolder, setAppFolder]);
|
||||
|
||||
const requestDeleteFolder = useCallback((folder: Folder) => {
|
||||
if ((folderContents.get(folder.id) ?? []).length === 0) deleteFolder(folder);
|
||||
else setConfirmDelete(folder);
|
||||
}, [folderContents, deleteFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setSurfaceSize({ width: window.innerWidth, height: window.innerHeight - MENUBAR_HEIGHT });
|
||||
@@ -70,18 +159,30 @@ export function Desktop() {
|
||||
active.moved = true;
|
||||
setDragging(active.id);
|
||||
setCandidate(cellAt(event.clientX, event.clientY));
|
||||
// Dragging an app over a folder icon highlights it as a drop target.
|
||||
if (!active.id.startsWith(FOLDER_ID_PREFIX)) {
|
||||
const hit = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>('[data-icon-id]');
|
||||
const over = hit?.dataset.iconId;
|
||||
setDropFolder(over?.startsWith(FOLDER_ID_PREFIX) && over !== active.id ? over : null);
|
||||
}
|
||||
}, [cellAt]);
|
||||
const finishPointer = useCallback((event: PointerEvent) => {
|
||||
const active = pointerStart.current;
|
||||
if (active?.moved) {
|
||||
const target = cellAt(event.clientX, event.clientY);
|
||||
move(active.id, target);
|
||||
const hit = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>('[data-icon-id]');
|
||||
const over = hit?.dataset.iconId;
|
||||
if (!active.id.startsWith(FOLDER_ID_PREFIX) && over?.startsWith(FOLDER_ID_PREFIX) && over !== active.id) {
|
||||
moveToFolder(active.id, over.slice(FOLDER_ID_PREFIX.length));
|
||||
} else {
|
||||
move(active.id, cellAt(event.clientX, event.clientY));
|
||||
}
|
||||
setSelected(active.id);
|
||||
}
|
||||
pointerStart.current = null;
|
||||
setDragging(null);
|
||||
setCandidate(null);
|
||||
}, [cellAt, move]);
|
||||
setDropFolder(null);
|
||||
}, [cellAt, move, moveToFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
@@ -114,7 +215,31 @@ export function Desktop() {
|
||||
} else {
|
||||
setPicked(id);
|
||||
setPickedLayout(slots);
|
||||
setAnnouncement(`${id} picked up. Use arrow keys to move, Enter to drop, Escape to cancel.`);
|
||||
setAnnouncement(`${id} picked up. Use arrow keys to move, F to move into a folder, Enter to drop, Escape to cancel.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!picked) return;
|
||||
if (event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault();
|
||||
if (id.startsWith(FOLDER_ID_PREFIX)) {
|
||||
setAnnouncement('Folders cannot be placed inside other folders.');
|
||||
return;
|
||||
}
|
||||
if (folders.length === 0) {
|
||||
setAnnouncement('No folders yet. Right-click an app and choose “New folder with it” to create one.');
|
||||
return;
|
||||
}
|
||||
setAnnouncement(`Move ${id} into which folder? Press 1 to ${Math.min(9, folders.length)}: ${folders.slice(0, 9).map((folder, index) => `${index + 1} for ${folder.name}`).join(', ')}.`);
|
||||
return;
|
||||
}
|
||||
if (/^[1-9]$/.test(event.key) && !id.startsWith(FOLDER_ID_PREFIX)) {
|
||||
const target = folders[Number(event.key) - 1];
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
setPicked(null);
|
||||
setPickedLayout(null);
|
||||
moveToFolder(id, target.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -122,12 +247,15 @@ export function Desktop() {
|
||||
ArrowLeft: { col: -1, row: 0 }, ArrowRight: { col: 1, row: 0 }, ArrowUp: { col: 0, row: -1 }, ArrowDown: { col: 0, row: 1 },
|
||||
};
|
||||
const offset = offsets[event.key];
|
||||
if (!offset || !picked) return;
|
||||
if (!offset) return;
|
||||
event.preventDefault();
|
||||
const target = { col: Math.max(0, Math.min(geometry.columns - 1, slot.col + offset.col)), row: Math.max(0, Math.min(geometry.rows - 1, slot.row + offset.row)) };
|
||||
move(id, target);
|
||||
};
|
||||
|
||||
const openFolder = openFolderId ? folders.find((folder) => folder.id === openFolderId) : undefined;
|
||||
const openFolderSlot = openFolder ? slots.find((item) => item.id === `${FOLDER_ID_PREFIX}${openFolder.id}`) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
@@ -140,31 +268,130 @@ export function Desktop() {
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0" aria-label="Desktop app grid">
|
||||
{apps.map((app) => {
|
||||
{topLevelApps.map((app) => {
|
||||
const slot = slots.find((item) => item.id === app.id);
|
||||
if (!slot) return null;
|
||||
const isCandidate = dragging === app.id && candidate;
|
||||
const displaySlot = isCandidate ? candidate : slot;
|
||||
return (
|
||||
<DesktopIcon
|
||||
key={app.id}
|
||||
app={app}
|
||||
selected={selected === app.id}
|
||||
dragging={dragging === app.id}
|
||||
pickedUp={picked === app.id}
|
||||
tabIndex={0}
|
||||
style={{ position: 'absolute', left: SURFACE_PADDING + displaySlot.col * CELL_WIDTH, top: SURFACE_PADDING + displaySlot.row * CELL_HEIGHT, zIndex: dragging === app.id ? 2 : 1 }}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
pointerStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false };
|
||||
setSelected(app.id);
|
||||
}}
|
||||
onKeyDown={(event) => iconKeyDown(app.id, event)}
|
||||
onSelect={() => { if (!pointerStart.current?.moved) setSelected(app.id); }}
|
||||
onOpen={() => openApp(app.id)}
|
||||
/>
|
||||
<ContextMenu key={app.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<DesktopIcon
|
||||
app={app}
|
||||
selected={selected === app.id}
|
||||
dragging={dragging === app.id}
|
||||
pickedUp={picked === app.id}
|
||||
tabIndex={0}
|
||||
style={{ position: 'absolute', left: SURFACE_PADDING + displaySlot.col * CELL_WIDTH, top: SURFACE_PADDING + displaySlot.row * CELL_HEIGHT, zIndex: dragging === app.id ? 2 : 1 }}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
pointerStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false };
|
||||
setSelected(app.id);
|
||||
}}
|
||||
onKeyDown={(event) => iconKeyDown(app.id, event)}
|
||||
onSelect={() => { if (!pointerStart.current?.moved) setSelected(app.id); }}
|
||||
onOpen={() => openApp(app.id)}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onSelect={() => openApp(app.id)}>Open</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Move to folder</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-48">
|
||||
{folders.length === 0 && <ContextMenuItem disabled>No folders yet</ContextMenuItem>}
|
||||
{folders.map((folder) => (
|
||||
<ContextMenuItem key={folder.id} onSelect={() => moveToFolder(app.id, folder.id)}>
|
||||
{folder.name}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
{folders.length > 0 && <ContextMenuSeparator />}
|
||||
<ContextMenuItem
|
||||
disabled={folders.length >= MAX_FOLDERS}
|
||||
onSelect={() => setFolderDialog({ open: true, appId: app.id })}
|
||||
>
|
||||
New folder with {app.title}…
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
|
||||
{folders.map((folder) => {
|
||||
const iconId = `${FOLDER_ID_PREFIX}${folder.id}`;
|
||||
const slot = slots.find((item) => item.id === iconId);
|
||||
if (!slot) return null;
|
||||
const contents = folderContents.get(folder.id) ?? [];
|
||||
const isCandidate = dragging === iconId && candidate;
|
||||
const displaySlot = isCandidate ? candidate : slot;
|
||||
return (
|
||||
<ContextMenu key={folder.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<FolderVisual
|
||||
label={folder.name}
|
||||
count={contents.length}
|
||||
badges={contents.slice(0, 3).map((app) => <app.icon key={app.id} className="size-2.5 text-primary" />)}
|
||||
selected={selected === iconId || openFolderId === folder.id}
|
||||
dragging={dragging === iconId}
|
||||
pickedUp={picked === iconId}
|
||||
dropTarget={dropFolder === iconId}
|
||||
tabIndex={0}
|
||||
style={{ position: 'absolute', left: SURFACE_PADDING + displaySlot.col * CELL_WIDTH, top: SURFACE_PADDING + displaySlot.row * CELL_HEIGHT, zIndex: dragging === iconId ? 2 : 1 }}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
pointerStart.current = { id: iconId, x: event.clientX, y: event.clientY, moved: false };
|
||||
setSelected(iconId);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
iconKeyDown(iconId, event);
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setOpenFolderId((current) => (current === folder.id ? null : folder.id));
|
||||
}
|
||||
}}
|
||||
onSelect={() => { if (!pointerStart.current?.moved) setSelected(iconId); }}
|
||||
onOpen={() => setOpenFolderId((current) => (current === folder.id ? null : folder.id))}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onSelect={() => setOpenFolderId(folder.id)}>Open</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => setFolderDialog({ open: true, folder })}>Rename…</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={() => requestDeleteFolder(folder)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Delete folder
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
|
||||
{openFolder && openFolderSlot && (
|
||||
<div
|
||||
className="absolute z-3"
|
||||
style={{
|
||||
left: Math.max(
|
||||
SURFACE_PADDING,
|
||||
Math.min(SURFACE_PADDING + openFolderSlot.col * CELL_WIDTH, surfaceSize.width - SURFACE_PADDING - 264),
|
||||
),
|
||||
top: SURFACE_PADDING + (openFolderSlot.row + 1) * CELL_HEIGHT + 4,
|
||||
}}
|
||||
>
|
||||
<FolderWindow
|
||||
folder={openFolder}
|
||||
appIds={(folderContents.get(openFolder.id) ?? []).map((app) => app.id)}
|
||||
onOpenApp={(appId) => openApp(appId)}
|
||||
onRemoveApp={removeFromFolder}
|
||||
onRename={() => setFolderDialog({ open: true, folder: openFolder })}
|
||||
onDelete={() => requestDeleteFolder(openFolder)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WindowLayer />
|
||||
@@ -172,6 +399,13 @@ export function Desktop() {
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuItem
|
||||
disabled={folders.length >= MAX_FOLDERS}
|
||||
onSelect={() => setFolderDialog({ open: true })}
|
||||
>
|
||||
New folder
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => openApp('feed')}>Open Feed</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => openApp('settings')}>Open Settings</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => { reset('desktop'); setAnnouncement('Desktop icon layout reset.'); }}>
|
||||
@@ -186,6 +420,36 @@ export function Desktop() {
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<FolderDialog
|
||||
key={folderDialog.folder?.id ?? folderDialog.appId ?? 'new'}
|
||||
open={folderDialog.open}
|
||||
onOpenChange={(open) => setFolderDialog((current) => ({ ...current, open }))}
|
||||
folders={folders}
|
||||
folder={folderDialog.folder}
|
||||
onSubmit={submitFolderDialog}
|
||||
/>
|
||||
|
||||
<AlertDialog open={confirmDelete !== null} onOpenChange={(open) => { if (!open) setConfirmDelete(null); }}>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete folder “{confirmDelete?.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The apps inside move back to the desktop. Nothing is uninstalled.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => { if (confirmDelete) deleteFolder(confirmDelete); }}
|
||||
>
|
||||
Delete folder
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<span id="icon-layout-status" className="sr-only" aria-live="polite">{announcement}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronLeft, LayoutGrid, Zap } from 'lucide-react';
|
||||
import { ChevronLeft, FolderPlus, LayoutGrid, Zap } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
@@ -12,11 +12,37 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FolderVisual } from './FolderIcon';
|
||||
import { FolderDialog } from './FolderDialog';
|
||||
import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { desktopApps, getApp } from '@/os/registry';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AppParams } from '@/os/types';
|
||||
import { useIconLayout } from '@/os/useIconLayout';
|
||||
import { useFolders } from '@/os/useFolders';
|
||||
import { FOLDER_ID_PREFIX } from './Desktop';
|
||||
import { MAX_FOLDERS, type Folder } from '@/os/folders';
|
||||
|
||||
const MOBILE_DRAG_THRESHOLD = 8;
|
||||
|
||||
@@ -165,12 +191,55 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [pickedOrder, setPickedOrder] = useState<string[] | null>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
|
||||
const [folderDialog, setFolderDialog] = useState<{ open: boolean; folder?: Folder; appId?: string }>({ open: false });
|
||||
const [confirmDelete, setConfirmDelete] = useState<Folder | null>(null);
|
||||
const dragStart = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null);
|
||||
const suppressClick = useRef(false);
|
||||
const targetRef = useRef<string | null>(null);
|
||||
const { layout, setMobile } = useIconLayout(apps.map((app) => app.id), { columns, rows: Math.max(8, Math.ceil(apps.length / columns) + 4) });
|
||||
const appIds = apps.map((app) => app.id);
|
||||
const { folderState, createFolder, renameFolder, removeFolder, setAppFolder } = useFolders(appIds);
|
||||
const { layout, setMobile } = useIconLayout(appIds, { columns, rows: Math.max(8, Math.ceil(apps.length / columns) + 4) }, folderState);
|
||||
const byId = useMemo(() => new Map(apps.map((app) => [app.id, app])), [apps]);
|
||||
const orderedApps = layout.mobile.map((id) => byId.get(id)).filter((app): app is NonNullable<typeof app> => Boolean(app));
|
||||
const { folders, membership } = folderState;
|
||||
const openFolder = openFolderId ? folders.find((folder) => folder.id === openFolderId) : undefined;
|
||||
|
||||
const folderContents = useMemo(() => {
|
||||
const map = new Map<string, typeof apps>();
|
||||
for (const folder of folders) map.set(folder.id, []);
|
||||
for (const app of apps) {
|
||||
const folderId = membership[app.id];
|
||||
if (folderId) map.get(folderId)?.push(app);
|
||||
}
|
||||
return map;
|
||||
}, [apps, folders, membership]);
|
||||
|
||||
const folderTitle = useCallback(
|
||||
(id: string | null | undefined) => folders.find((folder) => folder.id === id)?.name,
|
||||
[folders],
|
||||
);
|
||||
|
||||
const moveToFolder = (appId: string, folderId: string) => {
|
||||
setAppFolder(appId, folderId);
|
||||
setAnnouncement(`${appId} moved into folder ${folderTitle(folderId) ?? folderId}.`);
|
||||
};
|
||||
|
||||
const removeFromFolder = (appId: string) => {
|
||||
setAppFolder(appId, null);
|
||||
setAnnouncement(`${appId} moved out of ${folderTitle(membership[appId]) ?? 'its folder'} to the home screen.`);
|
||||
};
|
||||
|
||||
const deleteFolder = (folder: Folder) => {
|
||||
removeFolder(folder.id);
|
||||
setOpenFolderId((current) => (current === folder.id ? null : current));
|
||||
setConfirmDelete(null);
|
||||
setAnnouncement(`Folder ${folder.name} deleted. Its apps moved back to the home screen.`);
|
||||
};
|
||||
|
||||
const requestDeleteFolder = (folder: Folder) => {
|
||||
if ((folderContents.get(folder.id) ?? []).length === 0) deleteFolder(folder);
|
||||
else setConfirmDelete(folder);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setColumns(window.innerWidth < 480 ? 3 : 4);
|
||||
@@ -206,8 +275,12 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
const active = dragStart.current;
|
||||
if (active?.moved) {
|
||||
suppressClick.current = true;
|
||||
const target = targetRef.current;
|
||||
reorder(active.id, target && target !== active.id ? target : null);
|
||||
const over = targetRef.current;
|
||||
if (!active.id.startsWith(FOLDER_ID_PREFIX) && over?.startsWith(FOLDER_ID_PREFIX)) {
|
||||
moveToFolder(active.id, over.slice(FOLDER_ID_PREFIX.length));
|
||||
} else {
|
||||
reorder(active.id, over && over !== active.id ? over : null);
|
||||
}
|
||||
}
|
||||
dragStart.current = null;
|
||||
setDragging(null);
|
||||
@@ -222,6 +295,7 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
window.removeEventListener('pointerup', onEnd);
|
||||
window.removeEventListener('pointercancel', onEnd);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- moveToFolder is a plain function (the compiler refuses a useCallback here); listing it would re-arm the global listeners every render
|
||||
}, [reorder]);
|
||||
|
||||
const onKeyDown = (id: string, event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
@@ -243,11 +317,34 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
} else {
|
||||
setPicked(id);
|
||||
setPickedOrder(layout.mobile);
|
||||
setAnnouncement(`${id} picked up. Use arrow keys to reorder, Enter to drop, Escape to cancel.`);
|
||||
setAnnouncement(`${id} picked up. Use arrow keys to reorder, F to move into a folder, Enter to drop, Escape to cancel.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!picked) return;
|
||||
if (event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault();
|
||||
if (id.startsWith(FOLDER_ID_PREFIX)) {
|
||||
setAnnouncement('Folders cannot be placed inside other folders.');
|
||||
return;
|
||||
}
|
||||
if (folders.length === 0) {
|
||||
setAnnouncement('No folders yet. Create one with the button below the grid.');
|
||||
return;
|
||||
}
|
||||
setAnnouncement(`Move ${id} into which folder? Press 1 to ${Math.min(9, folders.length)}: ${folders.slice(0, 9).map((folder, folderIndex) => `${folderIndex + 1} for ${folder.name}`).join(', ')}.`);
|
||||
return;
|
||||
}
|
||||
if (/^[1-9]$/.test(event.key) && !id.startsWith(FOLDER_ID_PREFIX)) {
|
||||
const folder = folders[Number(event.key) - 1];
|
||||
if (folder) {
|
||||
event.preventDefault();
|
||||
setPicked(null);
|
||||
setPickedOrder(null);
|
||||
moveToFolder(id, folder.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const offset = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : event.key === 'ArrowUp' ? -columns : event.key === 'ArrowDown' ? columns : 0;
|
||||
if (!offset) return;
|
||||
event.preventDefault();
|
||||
@@ -262,47 +359,273 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
setAnnouncement(`${id} moved to position ${nextIndex + 1}.`);
|
||||
};
|
||||
|
||||
const appContextMenu = (appId: string, title: string, currentFolderId: string | null) => (
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onSelect={() => onOpen(appId)}>Open</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Move to folder</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-48">
|
||||
{currentFolderId && (
|
||||
<>
|
||||
<ContextMenuItem onSelect={() => removeFromFolder(appId)}>Home screen</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{folders.filter((folder) => folder.id !== currentFolderId).map((folder) => (
|
||||
<ContextMenuItem key={folder.id} onSelect={() => moveToFolder(appId, folder.id)}>
|
||||
{folder.name}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
{folders.length === 0 && <ContextMenuItem disabled>No folders yet</ContextMenuItem>}
|
||||
{folders.length > 0 && <ContextMenuSeparator />}
|
||||
<ContextMenuItem
|
||||
disabled={folders.length >= MAX_FOLDERS}
|
||||
onSelect={() => setFolderDialog({ open: true, appId })}
|
||||
>
|
||||
New folder with {title}…
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
);
|
||||
|
||||
const homeTileClass = (id: string) => cn(
|
||||
'flex flex-col items-center gap-2 rounded-xl p-2 transition-[transform,background-color,box-shadow] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-95',
|
||||
dragging === id && 'scale-105 bg-primary/15 shadow-lg',
|
||||
target === id && dragging !== id && 'bg-primary/10 ring-2 ring-primary/50',
|
||||
picked === id && 'bg-primary/15',
|
||||
);
|
||||
|
||||
if (openFolder) {
|
||||
const contents = folderContents.get(openFolder.id) ?? [];
|
||||
return (
|
||||
<div className="os-desktop-surface flex h-full flex-col overflow-y-auto">
|
||||
<div className="flex items-center gap-1 px-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenFolderId(null)}
|
||||
className="-ml-1 flex items-center gap-1 rounded px-1 py-1 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<ChevronLeft className="size-5" aria-hidden />
|
||||
Home
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="px-4 pb-1 pt-2 text-base font-semibold">{openFolder.name}</h2>
|
||||
{contents.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
This folder is empty. Long-press an app on the home screen to move it here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4 p-6 min-[480px]:grid-cols-4">
|
||||
{contents.map((app) => (
|
||||
<ContextMenu key={app.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(app.id)}
|
||||
className="flex flex-col items-center gap-2 rounded-xl p-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-95"
|
||||
>
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl border border-os-window-border bg-background shadow-sm">
|
||||
<app.icon className="size-7 text-primary" aria-hidden />
|
||||
</span>
|
||||
<span className="text-center text-xs font-medium leading-tight">{app.title}</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
{appContextMenu(app.id, app.title, openFolder.id)}
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto flex gap-2 p-4">
|
||||
<Button variant="outline" size="sm" onClick={() => setFolderDialog({ open: true, folder: openFolder })}>
|
||||
Rename
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => requestDeleteFolder(openFolder)}
|
||||
>
|
||||
Delete folder
|
||||
</Button>
|
||||
</div>
|
||||
<FolderDialog
|
||||
key={openFolder.id}
|
||||
open={folderDialog.open}
|
||||
onOpenChange={(open) => setFolderDialog((current) => ({ ...current, open }))}
|
||||
folders={folders}
|
||||
folder={folderDialog.folder}
|
||||
onSubmit={(name) => {
|
||||
if (folderDialog.folder) {
|
||||
renameFolder(folderDialog.folder.id, name);
|
||||
setAnnouncement(`Folder renamed to ${name}.`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DeleteFolderDialog folder={confirmDelete} onCancel={() => setConfirmDelete(null)} onConfirm={deleteFolder} />
|
||||
<span id="mobile-icon-layout-status" className="sr-only" aria-live="polite">{announcement}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = layout.mobile
|
||||
.map((id) => {
|
||||
if (id.startsWith(FOLDER_ID_PREFIX)) {
|
||||
const folder = folders.find((item) => item.id === id.slice(FOLDER_ID_PREFIX.length));
|
||||
return folder ? { type: 'folder' as const, id, folder } : null;
|
||||
}
|
||||
const app = byId.get(id);
|
||||
return app ? { type: 'app' as const, id, app } : null;
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
||||
|
||||
return (
|
||||
<div className="os-desktop-surface h-full overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-3 gap-4 min-[480px]:grid-cols-4">
|
||||
{orderedApps.map((app) => (
|
||||
<button
|
||||
key={app.id}
|
||||
type="button"
|
||||
data-home-icon-id={app.id}
|
||||
aria-pressed={picked === app.id || undefined}
|
||||
aria-describedby={picked === app.id ? 'mobile-icon-layout-status' : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
dragStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false };
|
||||
}}
|
||||
onClick={() => {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
onOpen(app.id);
|
||||
}}
|
||||
onKeyDown={(event) => onKeyDown(app.id, event)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2 rounded-xl p-2 transition-[transform,background-color,box-shadow] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-95',
|
||||
dragging === app.id && 'scale-105 bg-primary/15 shadow-lg',
|
||||
target === app.id && dragging !== app.id && 'bg-primary/10 ring-2 ring-primary/50',
|
||||
picked === app.id && 'bg-primary/15',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl border border-os-window-border bg-background shadow-sm">
|
||||
<app.icon className="size-7 text-primary" aria-hidden />
|
||||
</span>
|
||||
<span className="text-center text-xs font-medium leading-tight">{app.title}</span>
|
||||
</button>
|
||||
))}
|
||||
{entries.map((entry) =>
|
||||
entry.type === 'app' ? (
|
||||
<ContextMenu key={entry.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-home-icon-id={entry.id}
|
||||
aria-pressed={picked === entry.id || undefined}
|
||||
aria-describedby={picked === entry.id ? 'mobile-icon-layout-status' : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
dragStart.current = { id: entry.id, x: event.clientX, y: event.clientY, moved: false };
|
||||
}}
|
||||
onClick={() => {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
onOpen(entry.app.id);
|
||||
}}
|
||||
onKeyDown={(event) => onKeyDown(entry.id, event)}
|
||||
className={homeTileClass(entry.id)}
|
||||
>
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl border border-os-window-border bg-background shadow-sm">
|
||||
<entry.app.icon className="size-7 text-primary" aria-hidden />
|
||||
</span>
|
||||
<span className="text-center text-xs font-medium leading-tight">{entry.app.title}</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
{appContextMenu(entry.app.id, entry.app.title, null)}
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<ContextMenu key={entry.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<span data-home-icon-id={entry.id} className="contents">
|
||||
<FolderVisual
|
||||
label={entry.folder.name}
|
||||
count={(folderContents.get(entry.folder.id) ?? []).length}
|
||||
badges={(folderContents.get(entry.folder.id) ?? []).slice(0, 3).map((app) => (
|
||||
<app.icon key={app.id} className="size-2.5 text-primary" />
|
||||
))}
|
||||
selected={picked === entry.id}
|
||||
pickedUp={picked === entry.id}
|
||||
dragging={dragging === entry.id}
|
||||
dropTarget={target === entry.id && dragging !== entry.id}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
dragStart.current = { id: entry.id, x: event.clientX, y: event.clientY, moved: false };
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
setOpenFolderId(entry.folder.id);
|
||||
}}
|
||||
onOpen={() => setOpenFolderId(entry.folder.id)}
|
||||
onKeyDown={(event) => onKeyDown(entry.id, event)}
|
||||
className={homeTileClass(entry.id)}
|
||||
/>
|
||||
</span>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem onSelect={() => setOpenFolderId(entry.folder.id)}>Open</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => setFolderDialog({ open: true, folder: entry.folder })}>Rename…</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={() => requestDeleteFolder(entry.folder)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Delete folder
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{folders.length < MAX_FOLDERS && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setFolderDialog({ open: true })}
|
||||
>
|
||||
<FolderPlus className="size-4" aria-hidden />
|
||||
New folder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FolderDialog
|
||||
key={folderDialog.folder?.id ?? folderDialog.appId ?? 'new'}
|
||||
open={folderDialog.open}
|
||||
onOpenChange={(open) => setFolderDialog((current) => ({ ...current, open }))}
|
||||
folders={folders}
|
||||
folder={folderDialog.folder}
|
||||
onSubmit={(name) => {
|
||||
if (folderDialog.folder) {
|
||||
renameFolder(folderDialog.folder.id, name);
|
||||
setAnnouncement(`Folder renamed to ${name}.`);
|
||||
} else {
|
||||
const id = createFolder(name);
|
||||
if (folderDialog.appId) {
|
||||
setAppFolder(folderDialog.appId, id);
|
||||
setAnnouncement(`Folder ${name} created with ${folderDialog.appId} inside.`);
|
||||
} else {
|
||||
setAnnouncement(`Folder ${name} created.`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DeleteFolderDialog folder={confirmDelete} onCancel={() => setConfirmDelete(null)} onConfirm={deleteFolder} />
|
||||
<span id="mobile-icon-layout-status" className="sr-only" aria-live="polite">{announcement}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteFolderDialog({ folder, onCancel, onConfirm }: { folder: Folder | null; onCancel: () => void; onConfirm: (folder: Folder) => void }) {
|
||||
return (
|
||||
<AlertDialog open={folder !== null} onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete folder “{folder?.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The apps inside move back to the home screen. Nothing is uninstalled.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => { if (folder) onConfirm(folder); }}
|
||||
>
|
||||
Delete folder
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
import { reconcileFolderState, type FolderState } from './folders';
|
||||
|
||||
const STORAGE_KEY = 'nostr:icon-layout';
|
||||
const VERSION = 1;
|
||||
|
||||
/** Grid entries are app ids or folder entries (`folder:<id>`). */
|
||||
export type GridEntryId = string;
|
||||
|
||||
export interface DesktopSlot {
|
||||
id: string;
|
||||
col: number;
|
||||
@@ -27,8 +31,10 @@ const SlotSchema = z.object({
|
||||
|
||||
const LayoutSchema = z.object({
|
||||
version: z.literal(VERSION),
|
||||
desktop: z.array(SlotSchema).max(200),
|
||||
mobile: z.array(z.string().min(1)).max(200),
|
||||
desktop: z.array(SlotSchema).max(250),
|
||||
mobile: z.array(z.string().min(1)).max(250),
|
||||
// Pre-folders saves have no `folders` field; it defaults to empty.
|
||||
folders: z.unknown().optional(),
|
||||
});
|
||||
|
||||
function firstFree(occupied: Set<string>, geometry: GridGeometry): Omit<DesktopSlot, 'id'> {
|
||||
@@ -89,20 +95,33 @@ export function reconcileMobileLayout(saved: string[], ids: string[]): string[]
|
||||
return [...ordered, ...ids.filter((id) => !seen.has(id))];
|
||||
}
|
||||
|
||||
export function loadIconLayout(ids: string[], geometry: GridGeometry): IconLayout {
|
||||
export function loadIconLayout(ids: string[], geometry: GridGeometry, folders?: FolderState): IconLayout {
|
||||
const entries = entryIds(ids, folders);
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] };
|
||||
if (!raw) return { desktop: defaultDesktopLayout(entries, geometry), mobile: [...entries] };
|
||||
const parsed = LayoutSchema.parse(JSON.parse(raw));
|
||||
return {
|
||||
desktop: reconcileDesktopLayout(parsed.desktop, ids, geometry),
|
||||
mobile: reconcileMobileLayout(parsed.mobile, ids),
|
||||
desktop: reconcileDesktopLayout(parsed.desktop, entries, geometry),
|
||||
mobile: reconcileMobileLayout(parsed.mobile, entries),
|
||||
};
|
||||
} catch {
|
||||
return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] };
|
||||
return { desktop: defaultDesktopLayout(entries, geometry), mobile: [...entries] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconciles persisted folder state against the current app catalogue. */
|
||||
export function reconcileFolders(folders: FolderState | undefined, ids: string[]): FolderState {
|
||||
return reconcileFolderState(folders ?? { folders: [], membership: {} }, ids);
|
||||
}
|
||||
|
||||
/** The grid entry ids: top-level apps plus one entry per folder. */
|
||||
export function entryIds(ids: string[], folders?: FolderState): string[] {
|
||||
if (!folders) return ids;
|
||||
const clean = reconcileFolderState(folders, ids);
|
||||
return [...ids.filter((id) => !clean.membership[id]), ...clean.folders.map((folder) => `folder:${folder.id}`)];
|
||||
}
|
||||
|
||||
export function saveIconLayout(layout: IconLayout): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: VERSION, ...layout }));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
defaultDesktopLayout,
|
||||
entryIds,
|
||||
iconLayoutStorageKey,
|
||||
loadIconLayout,
|
||||
reconcileDesktopLayout,
|
||||
@@ -11,12 +12,21 @@ import {
|
||||
type GridGeometry,
|
||||
type IconLayout,
|
||||
} from './iconLayout';
|
||||
import type { FolderState } from './folders';
|
||||
|
||||
const SAVE_DELAY = 250;
|
||||
|
||||
export function useIconLayout(ids: string[], geometry: GridGeometry) {
|
||||
const idsKey = ids.join('|');
|
||||
const [layout, setLayout] = useState<IconLayout>(() => loadIconLayout(ids, geometry));
|
||||
/**
|
||||
* Tracks the position of every desktop grid entry (apps and, when `folders`
|
||||
* is given, folder icons). Apps assigned to a folder leave the grid — the
|
||||
* folder's entry takes their place.
|
||||
*/
|
||||
export function useIconLayout(ids: string[], geometry: GridGeometry, folders?: FolderState) {
|
||||
// Only the derived entry ids feed the hook's state, so a fresh `folders`
|
||||
// object each render does not reset anything.
|
||||
const entries = entryIds(ids, folders);
|
||||
const idsKey = entries.join('|');
|
||||
const [layout, setLayout] = useState<IconLayout>(() => loadIconLayout(entries, geometry));
|
||||
const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]);
|
||||
const stableGeometry = useMemo(
|
||||
() => ({ columns: geometry.columns, rows: geometry.rows }),
|
||||
|
||||
Reference in New Issue
Block a user