400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
import * as React from "react"
|
||
import {
|
||
ChevronRightIcon,
|
||
FilmIcon,
|
||
FolderIcon,
|
||
FolderOpenIcon,
|
||
HeartIcon,
|
||
ImagesIcon,
|
||
LibraryIcon,
|
||
MoreHorizontalIcon,
|
||
PencilIcon,
|
||
PlusIcon,
|
||
Trash2Icon,
|
||
UnlinkIcon,
|
||
} from "@workspace/ui/components/icon"
|
||
import { useTranslate } from "@workspace/i18n"
|
||
import { Button } from "@workspace/ui/components/button"
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@workspace/ui/components/collapsible"
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@workspace/ui/components/dropdown-menu"
|
||
import { cn } from "@workspace/ui/lib/utils"
|
||
|
||
import { mediaMessages } from "./messages"
|
||
import type {
|
||
MediaFilter,
|
||
MediaFolder,
|
||
MediaFolderSelection,
|
||
MediaId,
|
||
MediaKind,
|
||
} from "./types"
|
||
import {
|
||
getChildMediaFolders,
|
||
getMediaFolderDepth,
|
||
getMediaFolderPath,
|
||
} from "./utils"
|
||
|
||
const filters = [
|
||
{ icon: LibraryIcon, message: mediaMessages.allMedia, value: "all" },
|
||
{ icon: ImagesIcon, message: mediaMessages.image, value: "image" },
|
||
{ icon: FilmIcon, message: mediaMessages.video, value: "video" },
|
||
{ icon: HeartIcon, message: mediaMessages.favorite, value: "favorite" },
|
||
] as const satisfies readonly {
|
||
icon: React.ComponentType<{ className?: string }>
|
||
message: (typeof mediaMessages)[keyof typeof mediaMessages]
|
||
value: MediaFilter
|
||
}[]
|
||
|
||
export interface MediaSidebarProps {
|
||
allowedKinds?: readonly MediaKind[]
|
||
className?: string
|
||
filter: MediaFilter
|
||
folder: MediaFolderSelection
|
||
folders: readonly MediaFolder[]
|
||
onCreateFolder: (parentId: MediaId | null) => void
|
||
onDeleteFolder: (folder: MediaFolder) => void
|
||
onFilterChange: (filter: MediaFilter) => void
|
||
onFolderChange: (folder: MediaFolderSelection) => void
|
||
onRenameFolder: (folder: MediaFolder) => void
|
||
}
|
||
|
||
function visibleFilters(allowedKinds?: readonly MediaKind[]) {
|
||
return filters.filter(
|
||
(filter) =>
|
||
filter.value === "all" ||
|
||
filter.value === "favorite" ||
|
||
!allowedKinds ||
|
||
allowedKinds.includes(filter.value)
|
||
)
|
||
}
|
||
|
||
export function MediaSidebar({
|
||
allowedKinds,
|
||
className,
|
||
filter,
|
||
folder,
|
||
folders,
|
||
onCreateFolder,
|
||
onDeleteFolder,
|
||
onFilterChange,
|
||
onFolderChange,
|
||
onRenameFolder,
|
||
}: MediaSidebarProps) {
|
||
const t = useTranslate()
|
||
const rootFolders = getChildMediaFolders(folders, null)
|
||
|
||
return (
|
||
<aside
|
||
className={cn(
|
||
"flex min-h-0 flex-col border-e bg-muted/15 py-3",
|
||
className
|
||
)}
|
||
>
|
||
<h2 className="px-4 py-2 text-sm font-semibold">
|
||
{t(mediaMessages.library)}
|
||
</h2>
|
||
<nav aria-label={t(mediaMessages.library)} className="px-3">
|
||
<ul className="space-y-1">
|
||
{visibleFilters(allowedKinds).map(({ icon, message, value }) => (
|
||
<li key={value}>
|
||
<SidebarButton
|
||
active={folder === undefined && filter === value}
|
||
icon={icon}
|
||
label={t(message)}
|
||
onClick={() => onFilterChange(value)}
|
||
/>
|
||
</li>
|
||
))}
|
||
<li>
|
||
<SidebarButton
|
||
active={folder === "unclassified"}
|
||
icon={UnlinkIcon}
|
||
label={t(mediaMessages.unclassified)}
|
||
onClick={() => onFolderChange("unclassified")}
|
||
/>
|
||
</li>
|
||
</ul>
|
||
</nav>
|
||
|
||
<div className="mt-4 flex min-h-0 flex-1 flex-col border-t pt-3">
|
||
<div className="flex items-center px-4 py-1">
|
||
<h3 className="text-xs font-medium text-muted-foreground">
|
||
{t(mediaMessages.folder)}
|
||
</h3>
|
||
<Button
|
||
type="button"
|
||
size="icon-xs"
|
||
variant="ghost"
|
||
className="ms-auto"
|
||
aria-label={t(mediaMessages.createFolder)}
|
||
onClick={() => onCreateFolder(null)}
|
||
>
|
||
<PlusIcon />
|
||
</Button>
|
||
</div>
|
||
<nav
|
||
aria-label={t(mediaMessages.folder)}
|
||
className="min-h-0 overflow-y-auto px-3"
|
||
>
|
||
{rootFolders.length ? (
|
||
<ul className="space-y-1">
|
||
{rootFolders.map((rootFolder) => (
|
||
<FolderItem
|
||
key={rootFolder.id}
|
||
folder={rootFolder}
|
||
folders={folders}
|
||
depth={0}
|
||
activeFolder={folder}
|
||
onSelect={onFolderChange}
|
||
onCreateFolder={onCreateFolder}
|
||
onRename={onRenameFolder}
|
||
onDelete={onDeleteFolder}
|
||
/>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="w-full px-3 py-4 text-start text-xs text-muted-foreground hover:text-foreground"
|
||
onClick={() => onCreateFolder(null)}
|
||
>
|
||
{t(mediaMessages.noFolders)}
|
||
</button>
|
||
)}
|
||
</nav>
|
||
</div>
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
export function MediaFilterTabs({
|
||
allowedKinds,
|
||
filter,
|
||
folder,
|
||
folders,
|
||
onFilterChange,
|
||
onFolderChange,
|
||
}: Omit<
|
||
MediaSidebarProps,
|
||
"className" | "onCreateFolder" | "onDeleteFolder" | "onRenameFolder"
|
||
>) {
|
||
const t = useTranslate()
|
||
|
||
return (
|
||
<div className="flex gap-1 overflow-x-auto border-b p-2 md:hidden">
|
||
{visibleFilters(allowedKinds).map(({ icon: Icon, message, value }) => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
aria-pressed={folder === undefined && filter === value}
|
||
onClick={() => onFilterChange(value)}
|
||
className={cn(
|
||
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-xs",
|
||
folder === undefined && filter === value
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-muted"
|
||
)}
|
||
>
|
||
<Icon className="size-3.5" />
|
||
{t(message)}
|
||
</button>
|
||
))}
|
||
<select
|
||
aria-label={t(mediaMessages.folder)}
|
||
className="h-8 max-w-40 rounded-lg border bg-background px-2 text-xs outline-none"
|
||
value={
|
||
folder === undefined
|
||
? ""
|
||
: folder === "unclassified"
|
||
? "unclassified"
|
||
: String(folder)
|
||
}
|
||
onChange={(event) => {
|
||
const next = event.target.value
|
||
onFolderChange(
|
||
next === ""
|
||
? undefined
|
||
: next === "unclassified"
|
||
? "unclassified"
|
||
: (folders.find((item) => String(item.id) === next)?.id ?? next)
|
||
)
|
||
}}
|
||
>
|
||
<option value="">{t(mediaMessages.allMedia)}</option>
|
||
<option value="unclassified">{t(mediaMessages.unclassified)}</option>
|
||
{folders.map((item) => (
|
||
<option key={item.id} value={item.id}>
|
||
{" ".repeat(getMediaFolderDepth(item, folders))}
|
||
{getMediaFolderPath(item, folders)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SidebarButton({
|
||
active,
|
||
icon: Icon,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
active: boolean
|
||
icon: React.ComponentType<{ className?: string }>
|
||
label: string
|
||
onClick: VoidFunction
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-pressed={active}
|
||
onClick={onClick}
|
||
className={cn(
|
||
"flex h-9 w-full items-center gap-2 rounded-lg px-3 text-start text-sm transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/30",
|
||
active && "bg-primary text-primary-foreground hover:bg-primary"
|
||
)}
|
||
>
|
||
<Icon className="size-4" />
|
||
{label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function FolderItem({
|
||
folder,
|
||
folders,
|
||
depth,
|
||
activeFolder,
|
||
onSelect,
|
||
onCreateFolder,
|
||
onRename,
|
||
onDelete,
|
||
}: {
|
||
folder: MediaFolder
|
||
folders: readonly MediaFolder[]
|
||
depth: number
|
||
activeFolder: MediaFolderSelection
|
||
onSelect: (folder: MediaFolderSelection) => void
|
||
onCreateFolder: (parentId: MediaId | null) => void
|
||
onRename: (folder: MediaFolder) => void
|
||
onDelete: (folder: MediaFolder) => void
|
||
}) {
|
||
const t = useTranslate()
|
||
const children = getChildMediaFolders(folders, folder.id)
|
||
const [open, setOpen] = React.useState(true)
|
||
const hasChildren = children.length > 0
|
||
const active = activeFolder === folder.id
|
||
const Folder = active || (hasChildren && open) ? FolderOpenIcon : FolderIcon
|
||
|
||
return (
|
||
<li>
|
||
<Collapsible open={open} onOpenChange={setOpen}>
|
||
<div
|
||
className={cn(
|
||
"group flex h-9 items-center rounded-lg transition-colors hover:bg-muted",
|
||
active && "bg-secondary text-secondary-foreground"
|
||
)}
|
||
>
|
||
{hasChildren ? (
|
||
<CollapsibleTrigger
|
||
render={
|
||
<button
|
||
type="button"
|
||
className="group/folder-toggle relative ms-2 grid size-7 shrink-0 place-items-center rounded-md outline-none hover:bg-muted-foreground/10 focus-visible:ring-3 focus-visible:ring-ring/30"
|
||
aria-label={folder.name}
|
||
/>
|
||
}
|
||
>
|
||
<Folder className="size-4 transition-opacity group-hover/folder-toggle:opacity-0 group-focus-visible/folder-toggle:opacity-0" />
|
||
<ChevronRightIcon
|
||
className={cn(
|
||
"absolute size-4 opacity-0 transition-[rotate,opacity] group-hover/folder-toggle:opacity-100 group-focus-visible/folder-toggle:opacity-100",
|
||
open && "rotate-90"
|
||
)}
|
||
/>
|
||
</CollapsibleTrigger>
|
||
) : (
|
||
<span className="ms-2 grid size-7 shrink-0 place-items-center">
|
||
<Folder className="size-4" />
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
aria-label={t(mediaMessages.selectFolder, { name: folder.name })}
|
||
className="flex min-w-0 flex-1 items-center gap-2 self-stretch py-0 ps-1 pe-1.5 text-start text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
||
onClick={() => onSelect(folder.id)}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
|
||
{(folder.assetCount ?? 0) > 0 && (
|
||
<span className="text-xs text-muted-foreground">
|
||
{folder.assetCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger
|
||
render={
|
||
<Button
|
||
type="button"
|
||
size="icon-xs"
|
||
variant="ghost"
|
||
className="me-1 opacity-0 group-hover:opacity-100 aria-expanded:opacity-100"
|
||
aria-label={folder.name}
|
||
/>
|
||
}
|
||
>
|
||
<MoreHorizontalIcon />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{depth < 2 && (
|
||
<DropdownMenuItem onClick={() => onCreateFolder(folder.id)}>
|
||
<PlusIcon />
|
||
{t(mediaMessages.newChildFolder)}
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem onClick={() => onRename(folder)}>
|
||
<PencilIcon />
|
||
{t(mediaMessages.rename)}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onClick={() => onDelete(folder)}
|
||
>
|
||
<Trash2Icon />
|
||
{t(mediaMessages.delete)}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
{hasChildren && (
|
||
<CollapsibleContent>
|
||
<ul className="mt-1 space-y-1 ps-4">
|
||
{children.map((child) => (
|
||
<FolderItem
|
||
key={child.id}
|
||
folder={child}
|
||
folders={folders}
|
||
depth={depth + 1}
|
||
activeFolder={activeFolder}
|
||
onSelect={onSelect}
|
||
onCreateFolder={onCreateFolder}
|
||
onRename={onRename}
|
||
onDelete={onDelete}
|
||
/>
|
||
))}
|
||
</ul>
|
||
</CollapsibleContent>
|
||
)}
|
||
</Collapsible>
|
||
</li>
|
||
)
|
||
}
|