5868507bcc
Task #453. Recipient (e.g. yas) couldn't see folders that an owner (e.g. admin) had shared with them — the server returned them correctly via /note-folders/shared-with-me, but the rail's "Shared with me" section was gated on `mode === "rail"`. On the default Notes screen (My Notes tab + "All notes" selection) the rail is mounted in `chips-only` mode, so the section was never rendered. Most recipients only ever saw the default screen, so shared folders were effectively invisible. Fix: drop the `mode === "rail"` gate in folders-rail.tsx so the shared-with-me section renders in both modes. The section already uses `contents md:block` (so chips inline into the mobile horizontal strip), and its "Shared with me" label is already `hidden md:block`, so the rendering stays compact on mobile and labelled on desktop in both modes. No backend changes; the existing socket invalidations on `note-folder-shared` / `note-folder-unshared` already keep the list fresh in realtime. Out of scope: - FoldersBrowser (the grid/list of own folders in the main content area) intentionally not extended to also list shared folders; the rail entry is sufficient and matches the task's "as a chip alongside the own-folder chips" outcome on mobile and "Shared with me" sidebar section on desktop. - No changes to permissions, notifications, or per-note sharing. - Notes top-bar freeze (Task #452) is untouched.
679 lines
24 KiB
TypeScript
679 lines
24 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Folder,
|
|
FolderOpen,
|
|
FolderPlus,
|
|
Inbox as InboxIcon,
|
|
Layers,
|
|
MoreVertical,
|
|
Pencil,
|
|
Share2,
|
|
Trash2,
|
|
Users,
|
|
Check,
|
|
X,
|
|
} from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
type NoteFolder,
|
|
type SharedFolder,
|
|
useCreateFolder,
|
|
useDeleteFolder,
|
|
useSharedWithMeFolders,
|
|
useUpdateFolder,
|
|
userDisplayName,
|
|
} from "@/lib/notes-api";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
export type FolderSelection =
|
|
| { kind: "all" }
|
|
| { kind: "unfiled" }
|
|
| { kind: "folder"; id: number }
|
|
// A folder shared WITH the current user. Distinct kind because the
|
|
// notes inside come from a different endpoint, the rail row carries
|
|
// the owner's display name, and every write affordance is hidden.
|
|
| { kind: "shared-folder"; id: number };
|
|
|
|
export function selectionMatches(a: FolderSelection, b: FolderSelection): boolean {
|
|
if (a.kind !== b.kind) return false;
|
|
if (
|
|
(a.kind === "folder" && b.kind === "folder") ||
|
|
(a.kind === "shared-folder" && b.kind === "shared-folder")
|
|
) {
|
|
return a.id === b.id;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Module-scoped drag id. Used by both HTML5 drag (desktop) and the touch
|
|
// long-press fallback (mobile / iPad) so a single drop pipeline handles both.
|
|
let DRAGGING_NOTE_ID: number | null = null;
|
|
|
|
export function setDraggingNoteId(id: number | null) {
|
|
DRAGGING_NOTE_ID = id;
|
|
}
|
|
|
|
export function getDraggingNoteId(): number | null {
|
|
return DRAGGING_NOTE_ID;
|
|
}
|
|
|
|
// Touch fallback: NotesPage registers its drop handler so the NoteCard touch
|
|
// handlers can resolve a drop target by hit-testing the rendered rail.
|
|
let DROP_HANDLER: ((target: "unfiled" | number) => void) | null = null;
|
|
|
|
export function registerFolderDropHandler(
|
|
fn: ((target: "unfiled" | number) => void) | null,
|
|
) {
|
|
DROP_HANDLER = fn;
|
|
}
|
|
|
|
export function dispatchTouchDropAt(clientX: number, clientY: number): boolean {
|
|
if (typeof document === "undefined") return false;
|
|
const el = document.elementFromPoint(clientX, clientY);
|
|
if (!el) return false;
|
|
const drop = (el as HTMLElement).closest<HTMLElement>("[data-folder-drop]");
|
|
if (!drop || !DROP_HANDLER) return false;
|
|
const target = drop.getAttribute("data-folder-drop")!;
|
|
if (target === "unfiled") DROP_HANDLER("unfiled");
|
|
else {
|
|
const id = Number(target);
|
|
if (Number.isInteger(id)) DROP_HANDLER(id);
|
|
else return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Highlight (or clear) a folder row under the finger during a touch drag.
|
|
export function highlightTouchDropAt(clientX: number, clientY: number) {
|
|
if (typeof document === "undefined") return;
|
|
for (const node of Array.from(
|
|
document.querySelectorAll<HTMLElement>("[data-folder-drop]"),
|
|
)) {
|
|
node.setAttribute("data-drop-active", "false");
|
|
}
|
|
const el = document.elementFromPoint(clientX, clientY);
|
|
const drop = (el as HTMLElement | null)?.closest<HTMLElement>(
|
|
"[data-folder-drop]",
|
|
);
|
|
if (drop) drop.setAttribute("data-drop-active", "true");
|
|
}
|
|
|
|
export function clearTouchDropHighlights() {
|
|
if (typeof document === "undefined") return;
|
|
for (const node of Array.from(
|
|
document.querySelectorAll<HTMLElement>("[data-folder-drop]"),
|
|
)) {
|
|
node.setAttribute("data-drop-active", "false");
|
|
}
|
|
}
|
|
|
|
// Expose the touch drop pipeline to window in development so e2e tests can
|
|
// drive it deterministically without trying to synthesize browser-specific
|
|
// native touch events. Gated to non-production builds.
|
|
if (typeof window !== "undefined" && import.meta.env.MODE !== "production") {
|
|
(window as unknown as Record<string, unknown>).__notesTouchTest = {
|
|
setDraggingNoteId,
|
|
dispatchTouchDropAt,
|
|
highlightTouchDropAt,
|
|
clearTouchDropHighlights,
|
|
};
|
|
}
|
|
|
|
interface Props {
|
|
folders: NoteFolder[];
|
|
selected: FolderSelection;
|
|
onSelect: (s: FolderSelection) => void;
|
|
unfiledCount: number;
|
|
totalCount: number;
|
|
onDropNote: (target: "unfiled" | number) => void;
|
|
// Owner action: open the folder-share dialog. The page handles mounting
|
|
// <FolderShareDialog> so the rail stays focused on navigation/selection.
|
|
onShareFolder?: (folder: NoteFolder) => void;
|
|
// "rail" → full sidebar / chip rail with folder rows (default).
|
|
// "chips-only" → render only the All-notes / Unfiled / "+ New folder"
|
|
// chips. Folder rows are owned by the FoldersBrowser in the
|
|
// main content so test ids don't collide.
|
|
mode?: "rail" | "chips-only";
|
|
}
|
|
|
|
export function FoldersRail({
|
|
folders,
|
|
selected,
|
|
onSelect,
|
|
unfiledCount,
|
|
totalCount,
|
|
onDropNote,
|
|
onShareFolder,
|
|
mode = "rail",
|
|
}: Props) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
// Folders shared WITH the current user. Rendered in a dedicated section
|
|
// below the owner's folders so the two namespaces are visually distinct.
|
|
const { data: sharedFolders = [] } = useSharedWithMeFolders();
|
|
const [newName, setNewName] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [editingName, setEditingName] = useState("");
|
|
const [hoverTarget, setHoverTarget] = useState<string | null>(null);
|
|
const [pendingDelete, setPendingDelete] = useState<NoteFolder | null>(null);
|
|
const create = useCreateFolder();
|
|
const update = useUpdateFolder();
|
|
const del = useDeleteFolder();
|
|
const { toast } = useToast();
|
|
|
|
// Map server error → user-facing toast. 409 = duplicate folder name for
|
|
// this user; anything else is a generic failure.
|
|
const reportFolderError = (e: unknown, fallbackKey: string, fallback: string) => {
|
|
const status =
|
|
typeof e === "object" && e !== null && "status" in e
|
|
? Number((e as { status?: unknown }).status)
|
|
: 0;
|
|
if (status === 409) {
|
|
toast({
|
|
title: t("notes.folders.duplicateTitle", "Folder already exists"),
|
|
description: t(
|
|
"notes.folders.duplicateDesc",
|
|
"Pick a different name — you already have a folder with that name.",
|
|
),
|
|
variant: "destructive",
|
|
});
|
|
} else {
|
|
toast({ title: t(fallbackKey, fallback), variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const submitCreate = () => {
|
|
const name = newName.trim();
|
|
if (!name) return;
|
|
create.mutate(name, {
|
|
onSuccess: (folder) => {
|
|
setNewName("");
|
|
setCreating(false);
|
|
// Required by spec: newly-created folder is immediately selected so
|
|
// the user lands on its (empty) view.
|
|
onSelect({ kind: "folder", id: folder.id });
|
|
},
|
|
onError: (e) =>
|
|
reportFolderError(e, "notes.folders.createFailed", "Could not create folder"),
|
|
});
|
|
};
|
|
|
|
const renderRow = (
|
|
key: string,
|
|
{
|
|
icon,
|
|
label,
|
|
count,
|
|
isSelected,
|
|
onClick,
|
|
onDrop,
|
|
dropTargetTestId,
|
|
dropTarget,
|
|
action,
|
|
content,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: React.ReactNode;
|
|
count: number;
|
|
isSelected: boolean;
|
|
onClick: () => void;
|
|
onDrop?: () => void;
|
|
dropTargetTestId: string;
|
|
dropTarget?: "unfiled" | number;
|
|
action?: React.ReactNode;
|
|
content?: React.ReactNode;
|
|
},
|
|
) => {
|
|
const isHover = hoverTarget === key;
|
|
return (
|
|
<div
|
|
key={key}
|
|
data-testid={dropTargetTestId}
|
|
data-folder-drop={dropTarget !== undefined ? String(dropTarget) : undefined}
|
|
data-drop-active={isHover ? "true" : "false"}
|
|
onDragOver={(e) => {
|
|
if (!onDrop) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
if (hoverTarget !== key) setHoverTarget(key);
|
|
}}
|
|
onDragLeave={() => {
|
|
if (hoverTarget === key) setHoverTarget(null);
|
|
}}
|
|
onDrop={(e) => {
|
|
if (!onDrop) return;
|
|
e.preventDefault();
|
|
setHoverTarget(null);
|
|
onDrop();
|
|
}}
|
|
className={`group rounded-lg border transition shrink-0 md:shrink ${
|
|
isSelected
|
|
? "bg-foreground text-background border-foreground"
|
|
: "border-transparent hover:bg-slate-100"
|
|
} ${isHover ? "ring-2 ring-amber-400 ring-offset-1" : ""} data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[drop-active=true]:ring-offset-1`}
|
|
>
|
|
{content ?? (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start whitespace-nowrap"
|
|
>
|
|
<span className="shrink-0">{icon}</span>
|
|
<span className="flex-1 truncate">{label}</span>
|
|
<span
|
|
className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
|
isSelected
|
|
? "bg-background/20 text-background"
|
|
: "bg-slate-200/80 text-muted-foreground"
|
|
}`}
|
|
data-testid={`${dropTargetTestId}-count`}
|
|
>
|
|
{count}
|
|
</span>
|
|
{action}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
// Mobile: horizontal chip row above the notes grid.
|
|
// Desktop (md+): fixed sidebar to the start of the grid.
|
|
className="w-full md:w-56 shrink-0 flex md:block gap-1 md:gap-0 md:space-y-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0"
|
|
data-testid="notes-folders-rail"
|
|
>
|
|
<div className="hidden md:block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground px-2 mb-1">
|
|
{t("notes.folders.title", "Folders")}
|
|
</div>
|
|
|
|
{renderRow("all", {
|
|
icon: <Layers size={14} />,
|
|
label: t("notes.folders.all", "All notes"),
|
|
count: totalCount,
|
|
isSelected: selected.kind === "all",
|
|
onClick: () => onSelect({ kind: "all" }),
|
|
dropTargetTestId: "folder-drop-all",
|
|
})}
|
|
|
|
{renderRow("unfiled", {
|
|
icon: <InboxIcon size={14} />,
|
|
label: t("notes.folders.unfiled", "Unfiled"),
|
|
count: unfiledCount,
|
|
isSelected: selected.kind === "unfiled",
|
|
onClick: () => onSelect({ kind: "unfiled" }),
|
|
onDrop: () => onDropNote("unfiled"),
|
|
dropTargetTestId: "folder-drop-unfiled",
|
|
dropTarget: "unfiled",
|
|
})}
|
|
|
|
<div className="contents md:block md:pt-1">
|
|
{mode === "rail" && folders.map((f) => {
|
|
const isEditing = editingId === f.id;
|
|
const isSel = selected.kind === "folder" && selected.id === f.id;
|
|
return renderRow(`f:${f.id}`, {
|
|
icon: isSel ? <FolderOpen size={14} /> : <Folder size={14} />,
|
|
label: f.name,
|
|
count: f.noteCount,
|
|
isSelected: isSel,
|
|
onClick: () => onSelect({ kind: "folder", id: f.id }),
|
|
onDrop: () => onDropNote(f.id),
|
|
dropTargetTestId: `folder-drop-${f.id}`,
|
|
dropTarget: f.id,
|
|
content: isEditing ? (
|
|
<div className="flex items-center gap-1 px-2 py-1.5">
|
|
<Folder size={14} className="text-muted-foreground shrink-0" />
|
|
<Input
|
|
autoFocus
|
|
value={editingName}
|
|
onChange={(e) => setEditingName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && editingName.trim()) {
|
|
update.mutate(
|
|
{ id: f.id, name: editingName.trim() },
|
|
{
|
|
onSuccess: () => setEditingId(null),
|
|
onError: (e) =>
|
|
reportFolderError(
|
|
e,
|
|
"notes.folders.renameFailed",
|
|
"Could not rename folder",
|
|
),
|
|
},
|
|
);
|
|
}
|
|
if (e.key === "Escape") setEditingId(null);
|
|
}}
|
|
className="h-7 text-sm"
|
|
data-testid={`folder-rename-input-${f.id}`}
|
|
/>
|
|
<button
|
|
className="p-1 text-muted-foreground hover:text-foreground"
|
|
onClick={() => {
|
|
if (editingName.trim()) {
|
|
update.mutate(
|
|
{ id: f.id, name: editingName.trim() },
|
|
{
|
|
onSuccess: () => setEditingId(null),
|
|
onError: (e) =>
|
|
reportFolderError(
|
|
e,
|
|
"notes.folders.renameFailed",
|
|
"Could not rename folder",
|
|
),
|
|
},
|
|
);
|
|
}
|
|
}}
|
|
aria-label={t("common.save", "Save")}
|
|
data-testid={`folder-rename-save-${f.id}`}
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
className="p-1 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setEditingId(null)}
|
|
aria-label={t("common.cancel", "Cancel")}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-stretch">
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect({ kind: "folder", id: f.id })}
|
|
className="flex-1 flex items-center gap-2 px-2.5 py-1.5 text-sm text-start min-w-0 whitespace-nowrap"
|
|
data-testid={`folder-select-${f.id}`}
|
|
>
|
|
<span className="shrink-0">
|
|
{isSel ? <FolderOpen size={14} /> : <Folder size={14} />}
|
|
</span>
|
|
<span className="flex-1 truncate">{f.name}</span>
|
|
<span
|
|
className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
|
isSel
|
|
? "bg-background/20 text-background"
|
|
: "bg-slate-200/80 text-muted-foreground"
|
|
}`}
|
|
data-testid={`folder-drop-${f.id}-count`}
|
|
>
|
|
{f.noteCount}
|
|
</span>
|
|
{f.sharedWithCount > 0 && (
|
|
<span
|
|
className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${
|
|
isSel
|
|
? "bg-background/20 text-background"
|
|
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
|
|
}`}
|
|
title={t("notes.folders.sharedWithCount", {
|
|
defaultValue_one: "Shared with 1 person",
|
|
defaultValue_other: "Shared with {{count}} people",
|
|
count: f.sharedWithCount,
|
|
})}
|
|
data-testid={`folder-shared-badge-${f.id}`}
|
|
>
|
|
<Users size={10} />
|
|
{f.sharedWithCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
<div className="flex items-center pe-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={`p-1 rounded hover:bg-black/10 ${
|
|
isSel ? "text-background/80" : "text-muted-foreground"
|
|
}`}
|
|
aria-label={t("notes.folders.actions", "Folder actions")}
|
|
data-testid={`folder-menu-${f.id}`}
|
|
>
|
|
<MoreVertical size={14} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-44">
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
setEditingId(f.id);
|
|
setEditingName(f.name);
|
|
}}
|
|
data-testid={`folder-rename-${f.id}`}
|
|
>
|
|
<Pencil size={14} className="me-2" />
|
|
{t("notes.folders.rename", "Rename folder")}
|
|
</DropdownMenuItem>
|
|
{onShareFolder && (
|
|
<DropdownMenuItem
|
|
onSelect={() => onShareFolder(f)}
|
|
data-testid={`folder-share-${f.id}`}
|
|
>
|
|
<Share2 size={14} className="me-2" />
|
|
{t("notes.folders.share", "Share folder")}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
onSelect={() => setPendingDelete(f)}
|
|
className="text-red-600 focus:text-red-600"
|
|
data-testid={`folder-delete-${f.id}`}
|
|
>
|
|
<Trash2 size={14} className="me-2" />
|
|
{t("notes.folders.delete", "Delete folder")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
),
|
|
});
|
|
})}
|
|
</div>
|
|
|
|
{creating ? (
|
|
<div className="flex items-center gap-1 px-2 py-1.5 shrink-0">
|
|
<FolderPlus size={14} className="text-muted-foreground" />
|
|
<Input
|
|
autoFocus
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
placeholder={t("notes.folders.namePlaceholder", "Folder name")}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") submitCreate();
|
|
if (e.key === "Escape") {
|
|
setNewName("");
|
|
setCreating(false);
|
|
}
|
|
}}
|
|
className="h-7 text-sm"
|
|
data-testid="folder-create-input"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 px-1"
|
|
disabled={!newName.trim() || create.isPending}
|
|
onClick={submitCreate}
|
|
data-testid="folder-create-submit"
|
|
>
|
|
<Check size={14} />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setCreating(true)}
|
|
className="shrink-0 flex items-center gap-2 px-2.5 py-1.5 text-sm rounded-lg text-muted-foreground hover:bg-slate-100 hover:text-foreground whitespace-nowrap md:w-full"
|
|
data-testid="folder-create-open"
|
|
>
|
|
<FolderPlus size={14} />
|
|
<span>{t("notes.folders.new", "New folder")}</span>
|
|
</button>
|
|
)}
|
|
|
|
{sharedFolders.length > 0 && (
|
|
<div
|
|
className="contents md:block md:pt-3"
|
|
data-testid="shared-with-me-section"
|
|
>
|
|
<div className="hidden md:block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground px-2 mb-1 mt-3">
|
|
{t("notes.folders.sharedWithMe", "Shared with me")}
|
|
</div>
|
|
{sharedFolders.map((sf: SharedFolder) => {
|
|
const isSel =
|
|
selected.kind === "shared-folder" && selected.id === sf.id;
|
|
const ownerName = userDisplayName(
|
|
{
|
|
id: sf.ownerId,
|
|
username: sf.ownerUsername,
|
|
displayNameAr: sf.ownerDisplayNameAr,
|
|
displayNameEn: sf.ownerDisplayNameEn,
|
|
},
|
|
lang,
|
|
);
|
|
return (
|
|
<div
|
|
key={`shared:${sf.id}`}
|
|
className={`group rounded-lg border transition shrink-0 md:shrink ${
|
|
isSel
|
|
? "bg-foreground text-background border-foreground"
|
|
: "border-transparent hover:bg-slate-100"
|
|
}`}
|
|
data-testid={`shared-folder-row-${sf.id}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect({ kind: "shared-folder", id: sf.id })}
|
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start whitespace-nowrap"
|
|
data-testid={`shared-folder-select-${sf.id}`}
|
|
>
|
|
<span className="shrink-0">
|
|
<Share2 size={14} />
|
|
</span>
|
|
<span className="flex-1 min-w-0">
|
|
<span className="block truncate">{sf.name}</span>
|
|
<span
|
|
className={`block truncate text-[10px] ${
|
|
isSel ? "text-background/80" : "text-muted-foreground"
|
|
}`}
|
|
>
|
|
{t("notes.folders.sharedByName", "by {{name}}", {
|
|
name: ownerName,
|
|
})}
|
|
</span>
|
|
</span>
|
|
<span
|
|
className={`shrink-0 text-[10px] tabular-nums ${
|
|
isSel ? "text-background/80" : "text-muted-foreground"
|
|
}`}
|
|
data-testid={`shared-folder-count-${sf.id}`}
|
|
>
|
|
{sf.noteCount}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="hidden md:block text-[10px] text-muted-foreground/80 px-2 pt-1 leading-snug">
|
|
{t(
|
|
"notes.folders.dragHint",
|
|
"Drag a note onto a folder to move it.",
|
|
)}
|
|
</div>
|
|
|
|
<AlertDialog
|
|
open={pendingDelete !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPendingDelete(null);
|
|
}}
|
|
>
|
|
<AlertDialogContent
|
|
data-testid={
|
|
pendingDelete
|
|
? `folder-delete-dialog-${pendingDelete.id}`
|
|
: "folder-delete-dialog"
|
|
}
|
|
>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("notes.folders.deleteTitle", "Delete this folder?")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t(
|
|
"notes.folders.deleteConfirm",
|
|
"Delete this folder? Notes inside will move to Unfiled.",
|
|
)}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel
|
|
data-testid={
|
|
pendingDelete
|
|
? `folder-delete-cancel-${pendingDelete.id}`
|
|
: "folder-delete-cancel"
|
|
}
|
|
>
|
|
{t("common.cancel", "Cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
|
|
data-testid={
|
|
pendingDelete
|
|
? `folder-delete-confirm-${pendingDelete.id}`
|
|
: "folder-delete-confirm"
|
|
}
|
|
onClick={() => {
|
|
const target = pendingDelete;
|
|
if (!target) return;
|
|
del.mutate(target.id, {
|
|
onSuccess: () => {
|
|
if (
|
|
selected.kind === "folder" &&
|
|
selected.id === target.id
|
|
) {
|
|
onSelect({ kind: "all" });
|
|
}
|
|
},
|
|
onError: (e) =>
|
|
reportFolderError(
|
|
e,
|
|
"notes.folders.deleteFailed",
|
|
"Could not delete folder",
|
|
),
|
|
});
|
|
setPendingDelete(null);
|
|
}}
|
|
>
|
|
{t("notes.folders.deleteAction", "Delete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</aside>
|
|
);
|
|
}
|