2026-07-31 16:04:15 +08:00
|
|
|
|
import * as React from "react"
|
|
|
|
|
|
import {
|
|
|
|
|
|
ChevronRightIcon,
|
|
|
|
|
|
FilmIcon,
|
|
|
|
|
|
FolderIcon,
|
|
|
|
|
|
FolderOpenIcon,
|
|
|
|
|
|
HeartIcon,
|
|
|
|
|
|
ImagesIcon,
|
|
|
|
|
|
LibraryIcon,
|
|
|
|
|
|
MoreHorizontalIcon,
|
|
|
|
|
|
PencilIcon,
|
|
|
|
|
|
PlusIcon,
|
|
|
|
|
|
Trash2Icon,
|
|
|
|
|
|
UnlinkIcon,
|
2026-09-20 15:52:22 +08:00
|
|
|
|
} from "@workspace/ui/components/icon"
|
2026-07-31 16:04:15 +08:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|