Files
music-repo/packages/frontend/src/routes/Piece.tsx

357 lines
10 KiB
TypeScript

import { client } from "@/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useLoading } from "@/hooks/useLoading";
import { Updater } from "@/hooks/useStore";
import { timeout } from "@/lib/utils";
import { Label } from "@radix-ui/react-label";
import type { Attachment, Piece } from "backend/database";
import clsx from "clsx";
import { PieceId } from "common";
import { getMediaTypeForFilename } from "common/MediaType";
import { ELYSIA_FORM_DATA } from "elysia";
import { Download, Loader2, Trash, UploadCloud } from "lucide-react";
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
import { Link, useParams } from "react-router-dom";
export function Piece() {
const id = PieceId(useParams().pieceId!);
const piece = useLoading(() => client.piece.get({ query: { id } }));
const attachments = useLoading(() => client.piece({ pieceId: id }).attachment.get());
if (piece.isLoading || attachments.isLoading) {
return (
<div className="w-full h-full overflow-hidden flex items-center justify-center">
<div>Ładowanie</div>
</div>
);
}
return (
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
{piece.error !== null || attachments.error !== null ? (
`Wystąpił błąd: ${[piece.error?.value, attachments.error?.value].filter(Boolean).join(", ")}`
) : piece.data[0] === undefined ? (
"Utwór nie istnieje"
) : (<>
<div className="flex flex-col gap-4 p-4 border rounded">
<h3 className="font-bold text-lg">Utwór</h3>
<PieceForm piece={piece.data[0]} />
<h3 className="font-bold text-lg">Załączniki</h3>
<AttachmentForm pieceId={piece.data[0].pieceId} setAttachments={attachments.setData} />
</div>
<Attachments pieceId={piece.data[0].pieceId} attachments={attachments.data} setAttachments={attachments.setData} />
</>)}
</div>
);
}
namespace PieceForm {
export interface Props {
readonly piece: Piece;
}
}
function PieceForm(props: PieceForm.Props) {
const [name, setName] = useState(props.piece.name);
const [composer, setComposer] = useState(props.piece.composer ?? "");
const [lyricist, setLyricist] = useState(props.piece.lyricist ?? "");
const [arranger, setArranger] = useState(props.piece.arranger ?? "");
const nameId = useId();
const composerId = useId();
const lyricistId = useId();
const arrangerId = useId();
const [isLoading, setIsLoading] = useState(false);
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
const delay = timeout(250);
try {
setIsLoading(true);
const { error } = await client.piece({ pieceId: props.piece.pieceId }).put({
name,
composer: composer.length > 0 ? composer : null,
lyricist: lyricist.length > 0 ? lyricist : null,
arranger: arranger.length > 0 ? arranger : null,
});
if (error) {
console.error(error.value);
return;
}
} finally {
await delay;
setIsLoading(false);
}
}
return (
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="grid items-baseline grid-cols-4 gap-4">
<Label htmlFor={nameId} className="text-right">Tytuł</Label>
<Input
id={nameId}
className="col-span-3"
type="text"
value={name}
required
onChange={(e) => setName(e.target.value)}
/>
<Label htmlFor={composerId} className="text-right">Kompozytor</Label>
<Input
id={composerId}
className="col-span-3"
type="text"
value={composer}
onChange={(e) => setComposer(e.target.value)}
/>
<Label htmlFor={lyricistId} className="text-right">Słowa</Label>
<Input
id={lyricistId}
className="col-span-3"
type="text"
value={lyricist}
onChange={(e) => setLyricist(e.target.value)}
/>
<Label htmlFor={arrangerId} className="text-right">Opracowanie</Label>
<Input
id={arrangerId}
className="col-span-3"
type="text"
value={arranger}
onChange={(e) => setArranger(e.target.value)}
/>
</div>
<div className="flex flex-col-reverse items-end">
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Zapisz
</Button>
</div>
</form>
);
}
namespace Attachments {
export interface Props {
readonly pieceId: PieceId;
readonly attachments: readonly Attachment[];
readonly setAttachments: Updater<Attachment[]>;
}
}
function Attachments(props: Attachments.Props) {
return (
<div className="grow">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nazwa pliku</TableHead>
<TableHead>Typ</TableHead>
<TableHead className="text-center">Dodano</TableHead>
<TableHead className="text-center">Zmodyfikowano</TableHead>
<TableHead className="text-center">Akcje</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.attachments.map((attachment) => (
<AttachmentRow
key={attachment.attachmentId}
attachment={attachment}
setAttachments={props.setAttachments}
/>
))}
</TableBody>
</Table>
</div>
);
}
namespace AttachmentRow {
export interface Props {
readonly attachment: Attachment;
readonly setAttachments: Updater<Attachment[]>;
}
}
function AttachmentRow(props: AttachmentRow.Props) {
const download = useCallback(async () => {
const { error, data: _data } = await client
.piece({ pieceId: props.attachment.pieceId })
.attachment({ attachmentId: props.attachment.attachmentId })
.get();
if (error !== null) {
console.error(error.value);
return;
}
const data = _data as unknown as typeof _data[ELYSIA_FORM_DATA];
const url = URL.createObjectURL(data.data);
const a = document.createElement("a");
a.href = url;
a.download = data.filename;
a.click();
URL.revokeObjectURL(url);
}, [props.attachment.attachmentId, props.attachment.pieceId]);
const open = useCallback(async (event: MouseEvent<HTMLAnchorElement>) => {
if (props.attachment.mediaType !== "application/pdf") {
return;
}
event.preventDefault();
const { error, data: _data } = await client
.piece({ pieceId: props.attachment.pieceId })
.attachment({ attachmentId: props.attachment.attachmentId })
.get();
if (error !== null) {
console.error(error.value);
return;
}
const data = _data as unknown as typeof _data[ELYSIA_FORM_DATA];
const url = URL.createObjectURL(data.data);
window.open(url, "_target");
URL.revokeObjectURL(url);
}, [props.attachment.mediaType, props.attachment.attachmentId, props.attachment.pieceId]);
const doDelete = useCallback(async () => {
const { error } = await client
.piece({ pieceId: props.attachment.pieceId })
.attachment({ attachmentId: props.attachment.attachmentId })
.delete();
if (error !== null) {
console.error(error.value);
return;
}
props.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId));
}, [props.attachment.attachmentId, props.attachment.pieceId]);
return (
<TableRow>
<TableCell>
{props.attachment.mediaType === "application/vnd.recordare.musicxml"
|| props.attachment.mediaType === "application/vnd.recordare.musicxml+xml"
|| props.attachment.mediaType === "application/pdf" ? (
<Link className="underline" to={`attachment/${props.attachment.attachmentId}`} onClick={open}>
{props.attachment.filename}
</Link>
) : (
props.attachment.filename
)}
</TableCell>
<TableCell>{props.attachment.mediaType}</TableCell>
<TableCell className="text-center font-mono text-xs">
{props.attachment.createdAt}
{props.attachment.createdBy !== null && <><br />przez {props.attachment.createdBy}</>}
</TableCell>
<TableCell className="text-center font-mono text-xs">
{props.attachment.modifiedAt === null && props.attachment.modifiedBy === null ? "\u2014"
: props.attachment.modifiedAt !== null && props.attachment.modifiedBy === null ? props.attachment.modifiedAt
: props.attachment.modifiedAt === null ? `przez ${props.attachment.createdBy}`
: <>{props.attachment.createdAt}<br />przez {props.attachment.createdBy}</>}
</TableCell>
<TableCell className="text-center flex justify-center gap-4">
<Button type="button" variant="ghost" size="icon" title="Pobierz" onClick={download}>
<Download />
</Button>
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={doDelete}>
<Trash />
</Button>
</TableCell>
</TableRow>
);
}
namespace AttachmentForm {
export interface Props {
readonly pieceId: PieceId;
readonly setAttachments: Updater<Attachment[]>;
}
}
function AttachmentForm(props: AttachmentForm.Props) {
const [isLoading, setIsLoading] = useState(false);
const onDragOver: DragEventHandler<HTMLElement> = async (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
const onDrop: DragEventHandler<HTMLElement> = async (e) => {
e.preventDefault();
if (isLoading) {
return;
}
const delay = timeout(250);
try {
setIsLoading(true);
for (const file of e.dataTransfer.files) {
const mediaType = getMediaTypeForFilename(file.name);
if (mediaType === undefined) {
continue;
}
const { data, error } = await client.piece({ pieceId: props.pieceId }).attachment.post({
filename: file.name,
mediaType,
data: file,
});
if (error !== null) {
console.error(error.value);
continue;
}
props.setAttachments((prev) => {
const next = [...prev, data];
next.sort((a, b) => a.filename.localeCompare(b.filename));
return next;
});
}
} finally {
await delay;
setIsLoading(false);
}
}
return (
<div
className={clsx(
"flex flex-col border rounded px-4 py-8 items-center select-none bg-stone-100",
isLoading && "text-stone-400",
)}
onDragOver={onDragOver}
onDrop={onDrop}
>
{isLoading ? (<>
<Loader2 className="animate-spin" />
<div>Wysyłanie załączników</div>
</>) : (<>
<UploadCloud />
<div>Przeciągnij i upuść tutaj załączniki</div>
</>)}
</div>
);
}