357 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|