Files
webmail/components/files/files-header.tsx
T
Linus Rath 4ba39cef0e feat(files): rework the Files app layout and interactions
The Files app had two layouts (folders inline or in a tree), a back bar
and a permanent warning above the list, a toolbar of ten unlabelled
icons, and a Recent list whose entries did nothing. In the tree layout
folders could not be renamed or deleted at all, and the default view,
default sort and hidden-files settings were ignored.

It now follows the contacts and calendar layout:

- a full-height sidebar with a "+" New menu, My files with the folder
  tree, Starred, Recent and Shared with me; folders accept dropped items
- a header with collapsing breadcrumbs (also drop targets), search, sort,
  list/grid switch and a details toggle; selecting items swaps it for an
  action bar
- list rows open from the name and select from the row, with hover
  actions; the grid shows folders as chips and files as cards with
  lazy, cached thumbnails
- a details panel for the selection or the current folder, empty-folder
  and no-results states, a floating upload card, a dismissible
  stability warning, and keyboard navigation with the arrow keys
- touch: tap opens, long-press selects, a "+" button in narrow panes

The tree is built from the listing the store already fetches instead of
one full fetch per folder, so it is current after every change. Recent
files open in their folder, and the settings for view, sort, hidden
files and icons now apply. The inline/sidebar setting is gone.
2026-10-11 16:23:19 +02:00

429 lines
16 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import {
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
PanelRight, Download, Trash2, Scissors, Copy, Pencil, Share2, Clipboard,
MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw,
} from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
export type SortKey = "name" | "size" | "modified";
export type SortDir = "asc" | "desc";
export type ViewMode = "list" | "grid";
export interface Crumb {
/** Folder id; null for the root. */
id: string | null;
name: string;
path: string;
}
interface SelectionActions {
count: number;
/** Files among the selection, for the download count. */
fileCount: number;
canOpen: boolean;
canRename: boolean;
canShare: boolean;
onOpen: () => void;
onDownload: () => void;
onShare: () => void;
onRename: () => void;
onCut: () => void;
onCopy: () => void;
onDelete: () => void;
onClear: () => void;
}
interface FilesHeaderProps {
crumbs: Crumb[];
/** Pro shell: a label between the root and the account's folders. */
accountLabel?: string | null;
onNavigateCrumb: (crumb: Crumb) => void;
onNavigateAccountRoot?: () => void;
onDropOnCrumb: (names: string[], targetId: string | null) => void;
onMenuClick?: () => void;
menuOpen?: boolean;
searchQuery: string;
onSearchChange: (q: string) => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
sortKey: SortKey;
sortDir: SortDir;
onSortChange: (key: SortKey, dir: SortDir) => void;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
showDetails: boolean;
onToggleDetails: () => void;
clipboardCount: number;
onPaste: () => void;
onRefresh: () => void;
isLoading: boolean;
compact: boolean;
/** Hide the tools that only apply to a folder listing (Pro account picker). */
toolsHidden?: boolean;
selection: SelectionActions | null;
}
const MAX_VISIBLE_CRUMBS = 3;
export function FilesHeader({
crumbs,
accountLabel,
onNavigateCrumb,
onNavigateAccountRoot,
onDropOnCrumb,
onMenuClick,
menuOpen,
searchQuery,
onSearchChange,
searchInputRef,
sortKey,
sortDir,
onSortChange,
viewMode,
onViewModeChange,
showDetails,
onToggleDetails,
clipboardCount,
onPaste,
onRefresh,
isLoading,
compact,
toolsHidden,
selection,
}: FilesHeaderProps) {
const t = useTranslations("files");
const sortMenu = useContextMenu<null>();
const overflowMenu = useContextMenu<Crumb[]>();
const [searchOpen, setSearchOpen] = useState(false);
const [dropCrumb, setDropCrumb] = useState<string | null>(null);
const headerClass = "flex items-center gap-1 px-3 border-b border-border min-w-0 flex-shrink-0";
const headerStyle = { paddingBlock: "var(--density-header-py)" };
if (selection) {
return (
<div role="toolbar" aria-label={t("selection_actions")} className={cn(headerClass, "bg-accent/40")} style={headerStyle}>
<Button variant="ghost" size="icon" className="h-8 w-8 -ms-1" onClick={selection.onClear} aria-label={t("clear_selection")} title={t("clear_selection")}>
<X className="w-4 h-4" />
</Button>
<span className="text-sm font-medium me-2 whitespace-nowrap" aria-live="polite">
{t("selected_count", { count: selection.count })}
</span>
<div className="flex items-center gap-0.5 min-w-0 overflow-x-auto">
{selection.canOpen && (
<ToolButton icon={FolderOpen} label={t("open")} onClick={selection.onOpen} compact={compact} />
)}
{selection.fileCount > 0 && (
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact={compact} />
)}
{selection.canShare && (
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact={compact} />
)}
{selection.canRename && (
<ToolButton icon={Pencil} label={t("rename")} onClick={selection.onRename} compact />
)}
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
</div>
<div className="flex-1" />
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
</div>
);
}
// Deep paths keep the root and the last folders; the rest go in a menu.
const hidden = crumbs.length > MAX_VISIBLE_CRUMBS + 1 ? crumbs.slice(1, crumbs.length - MAX_VISIBLE_CRUMBS + 1) : [];
const visible = hidden.length > 0 ? [crumbs[0], ...crumbs.slice(crumbs.length - MAX_VISIBLE_CRUMBS + 1)] : crumbs;
const crumbDropProps = (crumb: Crumb, isLast: boolean) => isLast ? {} : {
onDragOver: (e: React.DragEvent) => {
if (!isInternalDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropCrumb(crumb.path);
},
onDragLeave: () => setDropCrumb((cur) => (cur === crumb.path ? null : cur)),
onDrop: (e: React.DragEvent) => {
e.preventDefault();
setDropCrumb(null);
const names = readDraggedNames(e);
if (names.length > 0) onDropOnCrumb(names, crumb.id);
},
};
const renderCrumb = (crumb: Crumb, index: number) => {
const isLast = crumb === crumbs[crumbs.length - 1];
return (
<li key={`${crumb.id ?? "root"}:${crumb.path}`} className="flex items-center min-w-0 shrink-0 last:shrink">
{index > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />}
<button
type="button"
onClick={() => onNavigateCrumb(crumb)}
aria-current={isLast ? "page" : undefined}
className={cn(
"px-1.5 py-1 rounded-md transition-colors truncate max-w-[16rem]",
isLast ? "font-semibold text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted",
dropCrumb === crumb.path && "bg-primary/10 text-foreground ring-1 ring-primary/40",
)}
{...crumbDropProps(crumb, isLast)}
>
{crumb.name}
</button>
</li>
);
};
const searchField = (
<div className={cn("relative", compact ? "flex-1" : "w-56")}>
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
ref={searchInputRef}
type="search"
role="searchbox"
aria-label={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
onSearchChange("");
if (compact) setSearchOpen(false);
(e.target as HTMLInputElement).blur();
}
}}
placeholder={t("search_placeholder")}
autoFocus={compact}
className="w-full h-8 rounded-md border border-input bg-background ps-8 pe-8 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-search-cancel-button]:hidden"
/>
{(searchQuery || compact) && (
<button
type="button"
onClick={() => { onSearchChange(""); if (compact) setSearchOpen(false); }}
className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted text-muted-foreground"
aria-label={t("clear_search")}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
if (compact && searchOpen && !toolsHidden) {
return (
<div className={headerClass} style={headerStyle}>
{searchField}
</div>
);
}
return (
<div role="toolbar" aria-label={t("toolbar")} className={headerClass} style={headerStyle}>
{onMenuClick && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -ms-1 shrink-0"
onClick={onMenuClick}
aria-label={t("open_folder_tree")}
aria-expanded={menuOpen}
>
<Menu className="w-4 h-4" />
</Button>
)}
<nav aria-label={t("breadcrumbs")} className="flex-1 min-w-0">
<ol className="flex items-center text-sm min-w-0">
{visible.slice(0, 1).map((c) => renderCrumb(c, 0))}
{accountLabel && (
<li className="flex items-center min-w-0 shrink-0">
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />
<button
type="button"
onClick={onNavigateAccountRoot}
className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted truncate max-w-[12rem]"
>
{accountLabel}
</button>
</li>
)}
{hidden.length > 0 && (
<li className="flex items-center shrink-0">
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />
<button
type="button"
className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
aria-label={t("show_path")}
aria-haspopup="menu"
onClick={(e) => overflowMenu.openContextMenu(anchorEvent(e.currentTarget), hidden)}
>
<MoreHorizontal className="w-4 h-4" />
</button>
</li>
)}
{visible.slice(1).map((c, i) => renderCrumb(c, i + 1))}
</ol>
</nav>
{!toolsHidden && (
<div className="flex items-center gap-1 shrink-0">
{clipboardCount > 0 && (
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={onPaste} title={t("paste_here")}>
<Clipboard className="w-4 h-4" />
<span className={cn(compact && "sr-only")}>{t("paste_count", { count: clipboardCount })}</span>
</Button>
)}
{compact ? (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSearchOpen(true)} aria-label={t("search_placeholder")}>
<Search className="w-4 h-4" />
</Button>
) : searchField}
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", sortMenu.contextMenu.isOpen && "bg-accent")}
aria-label={t("sort_by")}
title={t("sort_by")}
aria-haspopup="menu"
aria-expanded={sortMenu.contextMenu.isOpen}
onMouseDown={(e) => { if (sortMenu.contextMenu.isOpen) e.stopPropagation(); }}
onClick={(e) => {
if (sortMenu.contextMenu.isOpen) sortMenu.closeContextMenu();
else sortMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
}}
>
<ArrowUpDown className="w-4 h-4" />
</Button>
<div className="flex items-center rounded-md border border-border p-0.5" role="radiogroup" aria-label={t("view_mode")}>
{([["list", LayoutList, t("list_view")], ["grid", LayoutGrid, t("grid_view")]] as const).map(([mode, Icon, label]) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={viewMode === mode}
aria-label={label}
title={label}
onClick={() => onViewModeChange(mode)}
className={cn(
"h-6 w-7 flex items-center justify-center rounded transition-colors",
viewMode === mode ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="w-4 h-4" />
</button>
))}
</div>
{!compact && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onRefresh} disabled={isLoading} aria-label={t("refresh")} title={t("refresh")}>
<RefreshCw className={cn("w-4 h-4", isLoading && "animate-spin")} />
</Button>
)}
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
</div>
)}
<ContextMenu ref={sortMenu.menuRef} isOpen={sortMenu.contextMenu.isOpen} position={sortMenu.contextMenu.position} onClose={sortMenu.closeContextMenu}>
{([["name", t("name")], ["modified", t("modified")], ["size", t("size")]] as const).map(([key, label]) => (
<SortItem
key={key}
label={label}
checked={sortKey === key}
onClick={() => { onSortChange(key, sortKey === key ? sortDir : key === "name" ? "asc" : "desc"); sortMenu.closeContextMenu(); }}
/>
))}
<ContextMenuSeparator />
<SortItem
icon={ArrowUp}
label={t("settings_ascending")}
checked={sortDir === "asc"}
onClick={() => { onSortChange(sortKey, "asc"); sortMenu.closeContextMenu(); }}
/>
<SortItem
icon={ArrowDown}
label={t("settings_descending")}
checked={sortDir === "desc"}
onClick={() => { onSortChange(sortKey, "desc"); sortMenu.closeContextMenu(); }}
/>
</ContextMenu>
<ContextMenu ref={overflowMenu.menuRef} isOpen={overflowMenu.contextMenu.isOpen} position={overflowMenu.contextMenu.position} onClose={overflowMenu.closeContextMenu}>
{(overflowMenu.contextMenu.data ?? []).map((crumb) => (
<ContextMenuItem
key={crumb.path}
icon={Folder}
label={crumb.name}
onClick={() => { overflowMenu.closeContextMenu(); onNavigateCrumb(crumb); }}
/>
))}
</ContextMenu>
</div>
);
}
function ToolButton({ icon: Icon, label, onClick, compact, destructive }: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
compact?: boolean;
destructive?: boolean;
}) {
return (
<Button
variant="ghost"
size={compact ? "icon" : "sm"}
className={cn("h-8 shrink-0", compact ? "w-8" : "gap-1.5 px-2.5", destructive && "text-destructive hover:text-destructive hover:bg-destructive/10")}
onClick={onClick}
aria-label={label}
title={label}
>
<Icon className="w-4 h-4" />
{!compact && <span>{label}</span>}
</Button>
);
}
function DetailsToggle({ showDetails, onToggle, label }: { showDetails: boolean; onToggle: () => void; label: string }) {
return (
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8 shrink-0", showDetails && "bg-accent text-accent-foreground")}
onClick={onToggle}
aria-label={label}
aria-pressed={showDetails}
title={label}
>
<PanelRight className="w-4 h-4" />
</Button>
);
}
function SortItem({ label, checked, onClick, icon: Icon }: {
label: string;
checked: boolean;
onClick: () => void;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<button
type="button"
role="menuitemradio"
aria-checked={checked}
onClick={onClick}
className="w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted focus:outline-none focus:bg-muted"
>
<span className="w-4 h-4 shrink-0 flex items-center justify-center">
{checked && <Check className="w-4 h-4" />}
</span>
{Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground" />}
<span className="flex-1">{label}</span>
</button>
);
}