428 lines
11 KiB
TypeScript
428 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 { authors, created, DEBOUNCE, modified, SAVE_DELAY } from "@/snippets";
|
|
import clsx from "clsx";
|
|
import { PieceId, Updater } 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, refresh } = 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} refresh={refresh} />
|
|
</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) => pieceCache.create(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>;
|
|
readonly refresh: Effect.Effect<void>;
|
|
}
|
|
}
|
|
|
|
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) => pieceCache.create(piece.pieceId, piece)),
|
|
);
|
|
|
|
setFiles(SortedMap.remove(name));
|
|
})),
|
|
Effect.allWith({ concurrency: "unbounded" }),
|
|
);
|
|
|
|
yield* props.refresh;
|
|
|
|
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>
|
|
);
|
|
}
|