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

424 lines
11 KiB
TypeScript

import { denormalizePiece, pieceCache } from "@/cache";
import { client } from "@/client";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useCache } from "@/hooks/useCache";
import { useLoading } from "@/hooks/useLoading";
import { Updater } from "@/hooks/useStore";
import { authors, created, DEBOUNCE, modified, SAVE_DELAY } from "@/snippets";
import clsx from "clsx";
import { PieceId } from "common";
import * as Body from "common/Body";
import { getMediaTypeForFilename } from "common/MediaType";
import { Array, Cause, Effect, Fiber, Iterable, Match, Option, Order, pipe, Predicate, Scope, SortedMap } from "effect";
import { Import, Loader2, Plus } from "lucide-react";
import { DragEventHandler, FormEventHandler, useId, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
export function Pieces() {
const [name, setName] = useState("");
const [author, setAuthor] = useState("");
const [importDialogOpen, setImportDialogOpen] = useState(false);
const debounce = useRef(Effect.void);
const { isLoading, error, data: pieceIds } = useLoading(Effect.gen(function* () {
yield* debounce.current;
const data = yield* client.queryPieces({
name: name !== "" ? Option.some(name) : Option.none(),
author: author !== "" ? Option.some(author) : Option.none(),
offset: 0,
limit: 100,
});
return data;
}), [name, author]);
return (
<div className="p-4 overflow-y-auto flex flex-col items-start gap-4">
<div className="flex flex-row gap-4">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Plus />Dodaj utwór
</Button>
</DialogTrigger>
<AddPieceDialogContent />
</Dialog>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Import />Importuj utwory
</Button>
</DialogTrigger>
<ImportPiecesDialogContent setDialogOpen={setImportDialogOpen} />
</Dialog>
<Input
className="w-[32ch]"
type="text"
placeholder="Tytuł"
value={name}
onChange={(e) => {
setName(e.target.value);
debounce.current = DEBOUNCE;
}}
/>
<Input
className="w-[32ch]"
type="text"
placeholder="Twórcy"
value={author}
onChange={(e) => {
setAuthor(e.target.value);
debounce.current = DEBOUNCE;
}}
/>
</div>
<Table>
<TableHeader className="bg-white sticky top-0">
<TableRow>
<TableHead>Tytuł</TableHead>
<TableHead>Twórcy</TableHead>
<TableHead className="text-center">Dodano</TableHead>
<TableHead className="text-center">Zmodyfikowano</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} >
<div className="flex items-center justify-center gap-2">
<Loader2 className="animate-spin" />
Ładowanie
</div>
</TableCell>
</TableRow>
) : error !== null ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`}
</TableCell>
</TableRow>
) : (
pieceIds.map((pieceId) => <PieceRow key={pieceId} pieceId={pieceId} />)
)}
</TableBody>
</Table>
</div>
);
}
namespace PieceRow {
export interface Props {
readonly pieceId: PieceId;
}
}
function PieceRow(props: PieceRow.Props) {
const { isLoading, error, data: piece } = useCache(pieceCache, props.pieceId);
if (isLoading) {
return (
<TableRow>
<TableCell colSpan={4}>Ładowanie</TableCell>
</TableRow>
);
}
if (error !== null) {
return (
<TableRow>
<TableCell colSpan={4}>
Wystąpił błąd: {Match.value(error).pipe(
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
Match.tag("NotFound", () => "Utwór nie istnieje"),
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
Match.exhaustive,
)}
</TableCell>
</TableRow>
);
}
return (
<TableRow>
<TableCell>
<Link className="underline" to={piece.pieceId}>{piece.name}</Link>
</TableCell>
<TableCell>
{authors(piece)}
</TableCell>
<TableCell className="text-center text-xs">
{created(piece)}
</TableCell>
<TableCell className="text-center text-xs">
{modified(piece)}
</TableCell>
</TableRow>
);
}
function AddPieceDialogContent() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [composer, setComposer] = useState("");
const [lyricist, setLyricist] = useState("");
const [arranger, setArranger] = useState("");
const nameId = useId();
const composerId = useId();
const lyricistId = useId();
const arrangerId = useId();
const [isLoading, setIsLoading] = useState(false);
const onSubmit: FormEventHandler<HTMLFormElement> = (e) => Effect.gen(function* () {
e.preventDefault();
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
yield* Scope.addFinalizer(scope, Effect.sync(() => setIsLoading(false)));
setIsLoading(true);
const { pieceId } = yield* pipe(
client.createPiece({
name,
composer: composer.length > 0 ? Option.some(composer) : Option.none(),
lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(),
arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(),
}),
Effect.flatMap(denormalizePiece),
Effect.tap((piece) => Effect.sync(() => pieceCache.setFulfilledSucceed(piece.pieceId, piece))),
);
navigate(pieceId);
}));
}).pipe(Effect.runPromise);
return (
<DialogContent>
<form onSubmit={onSubmit}>
<DialogHeader>
<DialogTitle>Dodaj utwór</DialogTitle>
</DialogHeader>
<div className="grid items-baseline grid-cols-4 gap-4 py-4">
<Label htmlFor={nameId} className="text-right">Tytuł</Label>
<Input
id={nameId}
className="col-span-3"
type="text"
value={name}
autoFocus
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>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Dodaj
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
namespace ImportPiecesDialogContent {
export interface Props {
readonly setDialogOpen: Updater<boolean>;
}
}
function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) {
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState<SortedMap.SortedMap<string, readonly File[]>>(() => SortedMap.empty(Order.string));
const onDragOver: DragEventHandler<HTMLElement> = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
const onDrop: DragEventHandler<HTMLElement> = (e) => Effect.gen(function* () {
e.preventDefault();
setFiles((value) => {
for (const file of e.dataTransfer.files) {
const mediaType = getMediaTypeForFilename(file.name);
if (mediaType === undefined) {
continue;
}
const name = file.name.substring(0, file.name.lastIndexOf("."));
const entry = pipe(
value,
SortedMap.get(name),
Option.getOrElse(Array.empty),
Array.append(file),
);
value = SortedMap.set(value, name, entry);
}
return value;
});
}).pipe(Effect.runPromise);
const onSubmit: FormEventHandler<HTMLFormElement> = (e) => Effect.gen(function* () {
e.preventDefault();
if (isLoading) {
return;
}
const delay = yield* Effect.fork(SAVE_DELAY);
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
yield* Scope.addFinalizer(scope, Effect.gen(function* () {
yield* Fiber.join(delay);
setIsLoading(false);
}));
setIsLoading(true);
yield* pipe(
files,
Iterable.map(([name, files]) => Effect.gen(function* () {
const piece = yield* pipe(
client.createPiece({
name,
composer: Option.none(),
arranger: Option.none(),
lyricist: Option.none(),
}),
);
const attachments = yield* pipe(
files,
Array.map((file) => Effect.gen(function* () {
const mediaType = getMediaTypeForFilename(file.name);
if (mediaType === undefined) {
return;
}
// NOTE Apparently, file.bytes is not a thing in this context
const data = new Uint8Array(yield* Body.arrayBuffer(file));
const attachment = yield* client.createAttachment({
pieceId: piece.pieceId,
data,
filename: file.name,
mediaType,
});
return attachment;
})),
Effect.allWith({ concurrency: "unbounded" }),
Effect.map(Array.filter(Predicate.isNotUndefined)),
);
yield* pipe(
{ ...piece, attachments },
denormalizePiece,
Effect.tap((piece) => Effect.sync(() => pieceCache.setFulfilledSucceed(piece.pieceId, piece))),
);
})),
Effect.allWith({ concurrency: "unbounded" }),
);
props.setDialogOpen(false);
}));
}).pipe(Effect.runPromise);
return (
<DialogContent>
<form onSubmit={onSubmit}>
<DialogHeader>
<DialogTitle>Importuj utwory</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
{SortedMap.size(files) > 0 && (
<div className="max-h-[50vh] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tytuł</TableHead>
<TableHead className="text-center">Liczba plików</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pipe(
files,
SortedMap.entries,
Iterable.map(([name, files]) => (
<TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell className="text-center">{files.length}</TableCell>
</TableRow>
)),
)}
</TableBody>
</Table>
</div>
)}
<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}
>
<Import />
<div>Przeciągnij i upuść tutaj załączniki</div>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Importuj
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}