feat(media): extract workspace package
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user