Files
simple-react-app-kit/packages/media/src/sidebar.tsx
T

400 lines
12 KiB
TypeScript
Raw Normal View History

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>
)
}