From 0cec461ea1b0e56648a0f2c00d55b23ce5b99c20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:13:11 +0000 Subject: [PATCH] Integrate folders into desktop, mobile home screen, and settings Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- src/apps/settings/index.tsx | 13 +- src/components/os/Desktop.tsx | 318 +++++++++++++++++++-- src/components/os/MobileAppShell.tsx | 399 ++++++++++++++++++++++++--- src/os/iconLayout.ts | 33 ++- src/os/useIconLayout.ts | 16 +- 5 files changed, 703 insertions(+), 76 deletions(-) diff --git a/src/apps/settings/index.tsx b/src/apps/settings/index.tsx index e5bcdd4..848c346 100644 --- a/src/apps/settings/index.tsx +++ b/src/apps/settings/index.tsx @@ -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 (
+
); diff --git a/src/components/os/Desktop.tsx b/src/components/os/Desktop.tsx index 50e8645..cec1c84 100644 --- a/src/components/os/Desktop.tsx +++ b/src/components/os/Desktop.tsx @@ -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(null); const [candidate, setCandidate] = useState<{ col: number; row: number } | null>(null); + const [dropFolder, setDropFolder] = useState(null); const [picked, setPicked] = useState(null); const [pickedLayout, setPickedLayout] = useState(null); const [announcement, setAnnouncement] = useState(''); + const [folderDialog, setFolderDialog] = useState({ open: false }); + const [openFolderId, setOpenFolderId] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(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(); + 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('[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('[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 ( <> @@ -140,31 +268,130 @@ export function Desktop() { }} >
- {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 ( - { - 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)} - /> + + + { + 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)} + /> + + + openApp(app.id)}>Open + + + Move to folder + + {folders.length === 0 && No folders yet} + {folders.map((folder) => ( + moveToFolder(app.id, folder.id)}> + {folder.name} + + ))} + {folders.length > 0 && } + = MAX_FOLDERS} + onSelect={() => setFolderDialog({ open: true, appId: app.id })} + > + New folder with {app.title}… + + + + + ); })} + + {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 ( + + + )} + 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))} + /> + + + setOpenFolderId(folder.id)}>Open + setFolderDialog({ open: true, folder })}>Rename… + + requestDeleteFolder(folder)} + className="text-destructive focus:text-destructive" + > + Delete folder + + + + ); + })} + + {openFolder && openFolderSlot && ( +
+ app.id)} + onOpenApp={(appId) => openApp(appId)} + onRemoveApp={removeFromFolder} + onRename={() => setFolderDialog({ open: true, folder: openFolder })} + onDelete={() => requestDeleteFolder(openFolder)} + /> +
+ )}
@@ -172,6 +399,13 @@ export function Desktop() { + = MAX_FOLDERS} + onSelect={() => setFolderDialog({ open: true })} + > + New folder + + openApp('feed')}>Open Feed openApp('settings')}>Open Settings { reset('desktop'); setAnnouncement('Desktop icon layout reset.'); }}> @@ -186,6 +420,36 @@ export function Desktop() {
+ + setFolderDialog((current) => ({ ...current, open }))} + folders={folders} + folder={folderDialog.folder} + onSubmit={submitFolderDialog} + /> + + { if (!open) setConfirmDelete(null); }}> + + + Delete folder “{confirmDelete?.name}”? + + The apps inside move back to the desktop. Nothing is uninstalled. + + + + Cancel + { if (confirmDelete) deleteFolder(confirmDelete); }} + > + Delete folder + + + + + {announcement} ); diff --git a/src/components/os/MobileAppShell.tsx b/src/components/os/MobileAppShell.tsx index 3c9a23f..d15aaf2 100644 --- a/src/components/os/MobileAppShell.tsx +++ b/src/components/os/MobileAppShell.tsx @@ -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(null); const [pickedOrder, setPickedOrder] = useState(null); const [announcement, setAnnouncement] = useState(''); + const [openFolderId, setOpenFolderId] = useState(null); + const [folderDialog, setFolderDialog] = useState<{ open: boolean; folder?: Folder; appId?: string }>({ open: false }); + const [confirmDelete, setConfirmDelete] = useState(null); const dragStart = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null); const suppressClick = useRef(false); const targetRef = useRef(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 => Boolean(app)); + const { folders, membership } = folderState; + const openFolder = openFolderId ? folders.find((folder) => folder.id === openFolderId) : undefined; + + const folderContents = useMemo(() => { + const map = new Map(); + 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) => { @@ -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) => ( + + onOpen(appId)}>Open + + + Move to folder + + {currentFolderId && ( + <> + removeFromFolder(appId)}>Home screen + + + )} + {folders.filter((folder) => folder.id !== currentFolderId).map((folder) => ( + moveToFolder(appId, folder.id)}> + {folder.name} + + ))} + {folders.length === 0 && No folders yet} + {folders.length > 0 && } + = MAX_FOLDERS} + onSelect={() => setFolderDialog({ open: true, appId })} + > + New folder with {title}… + + + + + ); + + 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 ( +
+
+ +
+

{openFolder.name}

+ {contents.length === 0 ? ( +

+ This folder is empty. Long-press an app on the home screen to move it here. +

+ ) : ( +
+ {contents.map((app) => ( + + + + + {appContextMenu(app.id, app.title, openFolder.id)} + + ))} +
+ )} +
+ + +
+ setFolderDialog((current) => ({ ...current, open }))} + folders={folders} + folder={folderDialog.folder} + onSubmit={(name) => { + if (folderDialog.folder) { + renameFolder(folderDialog.folder.id, name); + setAnnouncement(`Folder renamed to ${name}.`); + } + }} + /> + setConfirmDelete(null)} onConfirm={deleteFolder} /> + {announcement} +
+ ); + } + + 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 => Boolean(entry)); + return (
- {orderedApps.map((app) => ( - - ))} + {entries.map((entry) => + entry.type === 'app' ? ( + + + + + {appContextMenu(entry.app.id, entry.app.title, null)} + + ) : ( + + + + ( + + ))} + 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)} + /> + + + + setOpenFolderId(entry.folder.id)}>Open + setFolderDialog({ open: true, folder: entry.folder })}>Rename… + + requestDeleteFolder(entry.folder)} + className="text-destructive focus:text-destructive" + > + Delete folder + + + + ), + )}
+ + {folders.length < MAX_FOLDERS && ( +
+ +
+ )} + + 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.`); + } + } + }} + /> + setConfirmDelete(null)} onConfirm={deleteFolder} /> {announcement}
); } +function DeleteFolderDialog({ folder, onCancel, onConfirm }: { folder: Folder | null; onCancel: () => void; onConfirm: (folder: Folder) => void }) { + return ( + { if (!open) onCancel(); }}> + + + Delete folder “{folder?.name}”? + + The apps inside move back to the home screen. Nothing is uninstalled. + + + + Cancel + { if (folder) onConfirm(folder); }} + > + Delete folder + + + + + ); +} + function MobileSkeleton() { return (
diff --git a/src/os/iconLayout.ts b/src/os/iconLayout.ts index 51ff0c7..84c6169 100644 --- a/src/os/iconLayout.ts +++ b/src/os/iconLayout.ts @@ -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:`). */ +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, geometry: GridGeometry): Omit { @@ -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 })); diff --git a/src/os/useIconLayout.ts b/src/os/useIconLayout.ts index 68a65e4..dfd470e 100644 --- a/src/os/useIconLayout.ts +++ b/src/os/useIconLayout.ts @@ -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(() => 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(() => loadIconLayout(entries, geometry)); const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]); const stableGeometry = useMemo( () => ({ columns: geometry.columns, rows: geometry.rows }),