mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 21:53:40 +02:00
Add folder state module, hook, dialog, folder icon and folder panel components
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
9c42510584
commit
32fbada1cf
11
package-lock.json
generated
11
package-lock.json
generated
@@ -6069,7 +6069,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6090,7 +6089,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6111,7 +6109,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6132,7 +6129,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6153,7 +6149,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6174,7 +6169,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6195,7 +6189,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6216,7 +6209,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6237,7 +6229,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6258,7 +6249,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6279,7 +6269,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
54
src/components/os/FolderDialog.test.tsx
Normal file
54
src/components/os/FolderDialog.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { FolderDialog } from './FolderDialog';
|
||||
import type { Folder } from '@/os/folders';
|
||||
|
||||
const folders: Folder[] = [{ id: 'f1', name: 'Social' }];
|
||||
|
||||
function renderDialog(overrides: Partial<Parameters<typeof FolderDialog>[0]> = {}) {
|
||||
const onSubmit = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<FolderDialog
|
||||
open
|
||||
folders={folders}
|
||||
onSubmit={onSubmit}
|
||||
onOpenChange={onOpenChange}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onSubmit, onOpenChange };
|
||||
}
|
||||
|
||||
describe('FolderDialog', () => {
|
||||
it('creates a folder with the normalized name', () => {
|
||||
const { onSubmit, onOpenChange } = renderDialog();
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' My Tools ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
expect(onSubmit).toHaveBeenCalledWith('My Tools');
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('shows an error when the name is empty', () => {
|
||||
const { onSubmit } = renderDialog();
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Give the folder a name.');
|
||||
});
|
||||
|
||||
it('shows an error for a duplicate name', () => {
|
||||
const { onSubmit } = renderDialog();
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'social' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('already exists');
|
||||
});
|
||||
|
||||
it('allows keeping the current name when renaming', () => {
|
||||
const { onSubmit } = renderDialog({ folder: folders[0] });
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Social');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
|
||||
expect(onSubmit).toHaveBeenCalledWith('Social');
|
||||
});
|
||||
});
|
||||
90
src/components/os/FolderDialog.tsx
Normal file
90
src/components/os/FolderDialog.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useId, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { folderNameTaken, normalizeFolderName, type Folder } from '@/os/folders';
|
||||
|
||||
interface FolderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Existing folder names, for the duplicate check. */
|
||||
folders: Folder[];
|
||||
/** Set when renaming; omitted when creating. */
|
||||
folder?: Folder;
|
||||
onSubmit: (name: string) => void;
|
||||
}
|
||||
|
||||
/** Small create/rename dialog with an inline duplicate-name error. */
|
||||
export function FolderDialog({ open, onOpenChange, folders, folder, onSubmit }: FolderDialogProps) {
|
||||
const errorId = useId();
|
||||
// Keyed remount resets the input; this only seeds the initial value.
|
||||
const [name, setName] = useState(() => folder?.name ?? '');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const normalized = normalizeFolderName(name);
|
||||
if (!normalized) {
|
||||
setError('Give the folder a name.');
|
||||
return;
|
||||
}
|
||||
if (folderNameTaken(folders, normalized, folder?.id)) {
|
||||
setError(`A folder named “${normalized}” already exists.`);
|
||||
return;
|
||||
}
|
||||
onSubmit(normalized);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{folder ? 'Rename folder' : 'New folder'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{folder
|
||||
? 'Apps inside stay put when you rename it.'
|
||||
: 'Group apps together on the desktop and the home screen.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5 py-4">
|
||||
<Label htmlFor="folder-name">Name</Label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
value={name}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
placeholder="e.g. Social"
|
||||
aria-invalid={Boolean(error) || undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
if (error) setError('');
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{folder ? 'Rename' : 'Create'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
96
src/components/os/FolderIcon.tsx
Normal file
96
src/components/os/FolderIcon.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Folder as FolderGlyph } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FolderVisualProps {
|
||||
/** The folder's display name. */
|
||||
label: string;
|
||||
/** Stacked behind the folder glyph, e.g. the icons of the apps inside. */
|
||||
badges: React.ReactNode[];
|
||||
/** How many apps are inside; announced to assistive tech. */
|
||||
count: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onOpen: () => void;
|
||||
onPointerDown?: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
tabIndex?: number;
|
||||
dragging?: boolean;
|
||||
pickedUp?: boolean;
|
||||
/** Highlighted while an app is dragged over the folder. */
|
||||
dropTarget?: boolean;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder on the desktop/home screen, styled after DesktopIcon: the glyph
|
||||
* carries up to three mini-badges with the icons of the apps inside.
|
||||
*/
|
||||
export function FolderVisual({
|
||||
label,
|
||||
badges,
|
||||
count,
|
||||
selected,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onPointerDown,
|
||||
onKeyDown,
|
||||
tabIndex,
|
||||
dragging,
|
||||
pickedUp,
|
||||
dropTarget,
|
||||
style,
|
||||
className,
|
||||
}: FolderVisualProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={tabIndex}
|
||||
style={style}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onOpen}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={`${label} — folder with ${count} ${count === 1 ? 'app' : 'apps'}`}
|
||||
aria-pressed={pickedUp || undefined}
|
||||
aria-describedby={pickedUp ? 'icon-layout-status' : undefined}
|
||||
className={cn(
|
||||
'group flex w-20 flex-col items-center gap-1.5 rounded-lg p-2 text-center transition-[background-color,transform,box-shadow] motion-reduce:transition-none',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
selected || pickedUp ? 'bg-primary/15' : 'hover:bg-foreground/5',
|
||||
dragging && 'scale-105 cursor-grabbing shadow-lg',
|
||||
dropTarget && 'ring-2 ring-primary/60 bg-primary/10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'relative flex size-12 items-center justify-center rounded-xl border bg-background/80 shadow-sm transition-transform',
|
||||
'group-hover:-translate-y-0.5 group-active:translate-y-0',
|
||||
selected ? 'border-primary/40' : 'border-os-window-border',
|
||||
)}
|
||||
>
|
||||
<FolderGlyph className="size-6 text-primary" aria-hidden />
|
||||
{badges.length > 0 && (
|
||||
<span className="absolute -bottom-1.5 left-1/2 flex -translate-x-1/2" aria-hidden>
|
||||
{badges.slice(0, 3).map((badge, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex size-4 items-center justify-center rounded-full border border-os-window-border bg-background shadow-sm',
|
||||
index > 0 && '-ml-1.5',
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-[11px] font-medium leading-tight text-foreground/80">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
79
src/components/os/FolderWindow.tsx
Normal file
79
src/components/os/FolderWindow.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { FolderOpen, Folder as FolderGlyph, LogOut, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getApp } from '@/os/registry';
|
||||
import type { Folder } from '@/os/folders';
|
||||
|
||||
interface FolderWindowProps {
|
||||
folder: Folder;
|
||||
/** App ids inside this folder, in registry order. */
|
||||
appIds: string[];
|
||||
onOpenApp: (appId: string) => void;
|
||||
onRemoveApp: (appId: string) => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contents of a desktop folder, shown as a floating panel below the
|
||||
* folder icon while it is open. Not an OS window — folders are lightweight.
|
||||
*/
|
||||
export function FolderWindow({ folder, appIds, onOpenApp, onRemoveApp, onRename, onDelete }: FolderWindowProps) {
|
||||
const apps = appIds
|
||||
.map((id) => getApp(id))
|
||||
.filter((app): app is NonNullable<typeof app> => Boolean(app));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={`Folder ${folder.name}`}
|
||||
className="w-64 rounded-xl border border-os-window-border bg-popover shadow-xl"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<FolderGlyph className="size-4 shrink-0 text-primary" aria-hidden />
|
||||
<span className="truncate text-[13px] font-semibold">{folder.name}</span>
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`Rename folder ${folder.name}`} onClick={onRename}>
|
||||
<Pencil className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`Delete folder ${folder.name}`} onClick={onDelete}>
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apps.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
This folder is empty. Drag an app onto the folder, or use its context menu to move it here.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="os-scroll max-h-64 overflow-y-auto p-1.5">
|
||||
{apps.map((app) => (
|
||||
<li key={app.id} className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenApp(app.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<app.icon className="size-4 shrink-0 text-primary" aria-hidden />
|
||||
<span className="truncate text-[13px]">{app.title}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove ${app.title} from ${folder.name}`}
|
||||
onClick={() => onRemoveApp(app.id)}
|
||||
>
|
||||
<LogOut className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
<FolderOpen className="size-3" aria-hidden />
|
||||
{apps.length} {apps.length === 1 ? 'app' : 'apps'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/os/folders.test.ts
Normal file
157
src/os/folders.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EMPTY_FOLDERS,
|
||||
MAX_FOLDER_NAME,
|
||||
addFolder,
|
||||
appsInFolder,
|
||||
clearFolderState,
|
||||
folderNameTaken,
|
||||
folderStorageKey,
|
||||
loadFolderState,
|
||||
normalizeFolderName,
|
||||
reconcileFolderState,
|
||||
removeFolder,
|
||||
renameFolder,
|
||||
saveFolderState,
|
||||
setAppFolder,
|
||||
type FolderState,
|
||||
} from './folders';
|
||||
|
||||
const stateWithFolder = (): { state: FolderState; folderId: string } => {
|
||||
const { state, folder } = addFolder(EMPTY_FOLDERS, 'Tools');
|
||||
return { state, folderId: folder.id };
|
||||
};
|
||||
|
||||
describe('normalizeFolderName', () => {
|
||||
it('trims and collapses whitespace', () => {
|
||||
expect(normalizeFolderName(' My Folder ')).toBe('My Folder');
|
||||
});
|
||||
|
||||
it('caps the length', () => {
|
||||
expect(normalizeFolderName('x'.repeat(100))).toHaveLength(MAX_FOLDER_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folderNameTaken', () => {
|
||||
it('matches case-insensitively and ignores the excluded folder', () => {
|
||||
const { state, folderId } = stateWithFolder();
|
||||
expect(folderNameTaken(state.folders, 'tools')).toBe(true);
|
||||
expect(folderNameTaken(state.folders, 'TOOLS')).toBe(true);
|
||||
expect(folderNameTaken(state.folders, 'Tools', folderId)).toBe(false);
|
||||
expect(folderNameTaken(state.folders, 'Other')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder operations', () => {
|
||||
it('creates folders with unique ids', () => {
|
||||
const first = addFolder(EMPTY_FOLDERS, 'One');
|
||||
const second = addFolder(first.state, 'Two');
|
||||
expect(second.state.folders).toHaveLength(2);
|
||||
expect(second.state.folders[0].id).not.toBe(second.state.folders[1].id);
|
||||
});
|
||||
|
||||
it('renames a folder without touching the others', () => {
|
||||
const { state, folderId } = stateWithFolder();
|
||||
const renamed = renameFolder(state, folderId, 'Utilities');
|
||||
expect(renamed.folders[0].name).toBe('Utilities');
|
||||
});
|
||||
|
||||
it('assigns an app to a folder and lists it', () => {
|
||||
const { state, folderId } = stateWithFolder();
|
||||
const assigned = setAppFolder(state, 'feed', folderId);
|
||||
expect(appsInFolder(assigned, folderId)).toEqual(['feed']);
|
||||
});
|
||||
|
||||
it('moves an app between folders', () => {
|
||||
const { state, folderId } = stateWithFolder();
|
||||
const { state: withTwo, folder: other } = addFolder(state, 'More');
|
||||
const assigned = setAppFolder(setAppFolder(withTwo, 'feed', folderId), 'feed', other.id);
|
||||
expect(appsInFolder(assigned, folderId)).toEqual([]);
|
||||
expect(appsInFolder(assigned, other.id)).toEqual(['feed']);
|
||||
});
|
||||
|
||||
it('returns an app to the top level with null', () => {
|
||||
const { state, folderId } = stateWithFolder();
|
||||
const assigned = setAppFolder(state, 'feed', folderId);
|
||||
const cleared = setAppFolder(assigned, 'feed', null);
|
||||
expect(cleared.membership).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores assignments to unknown folders', () => {
|
||||
const { state } = stateWithFolder();
|
||||
const assigned = setAppFolder(state, 'feed', 'no-such-folder');
|
||||
expect(assigned.membership).toEqual({});
|
||||
});
|
||||
|
||||
it('deleting a folder returns its apps to the top level', () => {
|
||||
const { state, folderId } = stateWithFolder();
|
||||
const assigned = setAppFolder(state, 'feed', folderId);
|
||||
const removed = removeFolder(assigned, folderId);
|
||||
expect(removed.folders).toEqual([]);
|
||||
expect(removed.membership).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileFolderState', () => {
|
||||
it('drops membership for unknown folders and apps', () => {
|
||||
const { state, folderId } = stateWithFolder();
|
||||
const dirty: FolderState = {
|
||||
folders: state.folders,
|
||||
membership: { feed: folderId, ghost: folderId, settings: 'unknown-folder' },
|
||||
};
|
||||
const clean = reconcileFolderState(dirty, ['feed', 'settings']);
|
||||
expect(clean.membership).toEqual({ feed: folderId });
|
||||
});
|
||||
|
||||
it('drops duplicate folder ids and empty names', () => {
|
||||
const { folderId } = stateWithFolder();
|
||||
const dirty: FolderState = {
|
||||
folders: [
|
||||
{ id: folderId, name: 'One' },
|
||||
{ id: folderId, name: 'Duplicate' },
|
||||
{ id: 'blank', name: ' ' },
|
||||
],
|
||||
membership: { feed: folderId, notes: 'blank' },
|
||||
};
|
||||
const clean = reconcileFolderState(dirty, ['feed', 'notes']);
|
||||
expect(clean.folders).toEqual([{ id: folderId, name: 'One' }]);
|
||||
expect(clean.membership).toEqual({ feed: folderId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('scopes the storage key per user with an anonymous fallback', () => {
|
||||
expect(folderStorageKey(null)).toBe('nostr:icon-layout');
|
||||
expect(folderStorageKey(undefined)).toBe('nostr:icon-layout');
|
||||
expect(folderStorageKey('abc123')).toBe('nostr:icon-layout:abc123');
|
||||
});
|
||||
|
||||
it('round-trips a folder state through localStorage', () => {
|
||||
const { state, folderId } = stateWithFolder();
|
||||
const assigned = setAppFolder(state, 'feed', folderId);
|
||||
saveFolderState(assigned, 'user-a');
|
||||
expect(loadFolderState('user-a', ['feed'])).toEqual(assigned);
|
||||
// A different user must not see it.
|
||||
expect(loadFolderState('user-b', ['feed'])).toEqual(EMPTY_FOLDERS);
|
||||
});
|
||||
|
||||
it('returns an empty state for corrupt payloads', () => {
|
||||
localStorage.setItem(folderStorageKey(null), '{not json');
|
||||
expect(loadFolderState(null, ['feed'])).toEqual(EMPTY_FOLDERS);
|
||||
localStorage.setItem(folderStorageKey(null), JSON.stringify({ version: 99, folders: [] }));
|
||||
expect(loadFolderState(null, ['feed'])).toEqual(EMPTY_FOLDERS);
|
||||
});
|
||||
|
||||
it('clears only the requested user key', () => {
|
||||
const { state } = stateWithFolder();
|
||||
saveFolderState(state, 'user-a');
|
||||
saveFolderState(state, null);
|
||||
clearFolderState('user-a');
|
||||
expect(loadFolderState('user-a', [])).toEqual(EMPTY_FOLDERS);
|
||||
expect(loadFolderState(null, [])).toEqual(state);
|
||||
});
|
||||
});
|
||||
138
src/os/folders.ts
Normal file
138
src/os/folders.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const BASE_STORAGE_KEY = 'nostr:icon-layout';
|
||||
const VERSION = 1;
|
||||
|
||||
export const MAX_FOLDERS = 32;
|
||||
export const MAX_FOLDER_NAME = 40;
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FolderState {
|
||||
/** Every folder, in creation order. */
|
||||
folders: Folder[];
|
||||
/** `appId -> folderId`; apps without an entry live at the top level. */
|
||||
membership: Record<string, string>;
|
||||
}
|
||||
|
||||
export const EMPTY_FOLDERS: FolderState = { folders: [], membership: {} };
|
||||
|
||||
const FolderSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(MAX_FOLDER_NAME),
|
||||
});
|
||||
|
||||
const FolderStateSchema = z.object({
|
||||
version: z.literal(VERSION),
|
||||
folders: z.array(FolderSchema).max(MAX_FOLDERS),
|
||||
membership: z.record(z.string().min(1), z.string().min(1)).refine(
|
||||
(membership) => Object.keys(membership).length <= 500,
|
||||
{ message: 'too many folder assignments' },
|
||||
),
|
||||
});
|
||||
|
||||
/** Per-user key when signed in, the shared anonymous key otherwise. */
|
||||
export function folderStorageKey(userKey?: string | null): string {
|
||||
return userKey ? `${BASE_STORAGE_KEY}:${userKey}` : BASE_STORAGE_KEY;
|
||||
}
|
||||
|
||||
export function normalizeFolderName(input: string): string {
|
||||
return input.trim().replace(/\s+/g, ' ').slice(0, MAX_FOLDER_NAME);
|
||||
}
|
||||
|
||||
/** Case-insensitive duplicate check used by the create/rename dialog. */
|
||||
export function folderNameTaken(folders: Folder[], name: string, excludeId?: string): boolean {
|
||||
const needle = name.toLowerCase();
|
||||
return folders.some((folder) => folder.id !== excludeId && folder.name.toLowerCase() === needle);
|
||||
}
|
||||
|
||||
/** Drops assignments to folders/apps that no longer exist and dedupes folder ids. */
|
||||
export function reconcileFolderState(state: FolderState, appIds: string[]): FolderState {
|
||||
const seen = new Set<string>();
|
||||
const folders: Folder[] = [];
|
||||
for (const folder of state.folders) {
|
||||
if (seen.has(folder.id)) continue;
|
||||
const name = normalizeFolderName(folder.name);
|
||||
if (!name) continue;
|
||||
seen.add(folder.id);
|
||||
folders.push({ id: folder.id, name });
|
||||
}
|
||||
const eligible = new Set(folders.map((folder) => folder.id));
|
||||
const knownApps = new Set(appIds);
|
||||
const membership: Record<string, string> = {};
|
||||
for (const [appId, folderId] of Object.entries(state.membership)) {
|
||||
if (eligible.has(folderId) && knownApps.has(appId)) membership[appId] = folderId;
|
||||
}
|
||||
return { folders, membership };
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function createFolderId(): string {
|
||||
counter = (counter + 1) % 36 ** 4;
|
||||
return `f${Date.now().toString(36)}${counter.toString(36)}`;
|
||||
}
|
||||
|
||||
export function addFolder(state: FolderState, name: string): { state: FolderState; folder: Folder } {
|
||||
const folder = { id: createFolderId(), name };
|
||||
return { state: { ...state, folders: [...state.folders, folder] }, folder };
|
||||
}
|
||||
|
||||
export function renameFolder(state: FolderState, folderId: string, name: string): FolderState {
|
||||
return {
|
||||
...state,
|
||||
folders: state.folders.map((folder) => (folder.id === folderId ? { ...folder, name } : folder)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes the folder; its apps return to the top level. */
|
||||
export function removeFolder(state: FolderState, folderId: string): FolderState {
|
||||
const membership: Record<string, string> = {};
|
||||
for (const [appId, assigned] of Object.entries(state.membership)) {
|
||||
if (assigned !== folderId) membership[appId] = assigned;
|
||||
}
|
||||
return { folders: state.folders.filter((folder) => folder.id !== folderId), membership };
|
||||
}
|
||||
|
||||
/** Assigns an app to a folder, or back to the top level with `folderId: null`. */
|
||||
export function setAppFolder(state: FolderState, appId: string, folderId: string | null): FolderState {
|
||||
const membership = { ...state.membership };
|
||||
if (folderId && state.folders.some((folder) => folder.id === folderId)) membership[appId] = folderId;
|
||||
else delete membership[appId];
|
||||
return { ...state, membership };
|
||||
}
|
||||
|
||||
export function appsInFolder(state: FolderState, folderId: string): string[] {
|
||||
return Object.entries(state.membership)
|
||||
.filter(([, assigned]) => assigned === folderId)
|
||||
.map(([appId]) => appId);
|
||||
}
|
||||
|
||||
export function loadFolderState(userKey: string | null | undefined, appIds: string[]): FolderState {
|
||||
try {
|
||||
const raw = localStorage.getItem(folderStorageKey(userKey));
|
||||
if (!raw) return EMPTY_FOLDERS;
|
||||
return reconcileFolderState(FolderStateSchema.parse(JSON.parse(raw)), appIds);
|
||||
} catch {
|
||||
return EMPTY_FOLDERS;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFolderState(state: FolderState, userKey: string | null | undefined): void {
|
||||
try {
|
||||
localStorage.setItem(folderStorageKey(userKey), JSON.stringify({ version: VERSION, ...state }));
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing; the in-memory state remains usable.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearFolderState(userKey: string | null | undefined): void {
|
||||
try {
|
||||
localStorage.removeItem(folderStorageKey(userKey));
|
||||
} catch {
|
||||
// The caller still resets its in-memory state.
|
||||
}
|
||||
}
|
||||
94
src/os/useFolders.ts
Normal file
94
src/os/useFolders.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
EMPTY_FOLDERS,
|
||||
addFolder,
|
||||
clearFolderState,
|
||||
loadFolderState,
|
||||
reconcileFolderState,
|
||||
removeFolder,
|
||||
renameFolder,
|
||||
saveFolderState,
|
||||
setAppFolder,
|
||||
type FolderState,
|
||||
} from './folders';
|
||||
|
||||
const SAVE_DELAY = 250;
|
||||
|
||||
/**
|
||||
* Folder state for the desktop/home-screen icons. Persists per signed-in
|
||||
* user (anonymous key when signed out) so the structure survives sign-in/out
|
||||
* cycles for both identities.
|
||||
*/
|
||||
export function useFolders(appIds: string[]) {
|
||||
const { user } = useCurrentUser();
|
||||
const userKey = user?.pubkey ?? null;
|
||||
const idsKey = appIds.join('|');
|
||||
const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]);
|
||||
|
||||
const [state, setState] = useState<FolderState>(() => loadFolderState(userKey, stableIds));
|
||||
// Render-phase sync for a key change (sign-in/out): the same pattern
|
||||
// useLocalStorage uses for its storageKey argument.
|
||||
const [loadedKey, setLoadedKey] = useState(userKey);
|
||||
if (loadedKey !== userKey) {
|
||||
setLoadedKey(userKey);
|
||||
setState(loadFolderState(userKey, stableIds));
|
||||
}
|
||||
|
||||
const normalized = useMemo(() => reconcileFolderState(state, stableIds), [state, stableIds]);
|
||||
|
||||
const dirty = useRef(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!dirty.current) return;
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
dirty.current = false;
|
||||
saveFolderState(normalized, userKey);
|
||||
}, SAVE_DELAY);
|
||||
return () => clearTimeout(saveTimer.current);
|
||||
}, [normalized, userKey]);
|
||||
|
||||
// A reset from another consumer (e.g. Settings) reloads the current key.
|
||||
useEffect(() => {
|
||||
const onReset = () => setState(loadFolderState(userKey, stableIds));
|
||||
window.addEventListener('icon-layout-reset', onReset);
|
||||
return () => window.removeEventListener('icon-layout-reset', onReset);
|
||||
}, [userKey, stableIds]);
|
||||
|
||||
const update = useCallback((updater: (current: FolderState) => FolderState) => {
|
||||
dirty.current = true;
|
||||
setState((current) => updater(reconcileFolderState(current, stableIds)));
|
||||
}, [stableIds]);
|
||||
|
||||
const createFolder = useCallback((name: string): string => {
|
||||
const id = addFolder(normalized, name).folder.id;
|
||||
update((current) => addFolder(current, name).state);
|
||||
return id;
|
||||
}, [normalized, update]);
|
||||
|
||||
const rename = useCallback((folderId: string, name: string) => {
|
||||
update((current) => renameFolder(current, folderId, name));
|
||||
}, [update]);
|
||||
|
||||
const remove = useCallback((folderId: string) => {
|
||||
update((current) => removeFolder(current, folderId));
|
||||
}, [update]);
|
||||
|
||||
const assign = useCallback((appId: string, folderId: string | null) => {
|
||||
update((current) => setAppFolder(current, appId, folderId));
|
||||
}, [update]);
|
||||
|
||||
const resetFolders = useCallback(() => {
|
||||
clearFolderState(userKey);
|
||||
dirty.current = false;
|
||||
clearTimeout(saveTimer.current);
|
||||
setState(EMPTY_FOLDERS);
|
||||
window.dispatchEvent(new Event('icon-layout-reset'));
|
||||
}, [userKey]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ folderState: normalized, createFolder, renameFolder: rename, removeFolder: remove, setAppFolder: assign, resetFolders }),
|
||||
[normalized, createFolder, rename, remove, assign, resetFolders],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user