Integrate folders into desktop, mobile home screen, and settings

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 15:13:11 +00:00
committed by GitHub
parent 32fbada1cf
commit 0cec461ea1
5 changed files with 703 additions and 76 deletions

View File

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

View File

@@ -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>
</>
);

View File

@@ -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">

View File

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

View File

@@ -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 }),