Clone attachment to other piece
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"@radix-ui",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -316,14 +316,26 @@ export const handle = implement(api, {
|
||||
const { userId } = yield* requireOneOf(WRITE_ACCESS);
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(attachment.data, sha256);
|
||||
let sha256: Sha256;
|
||||
if ("sha256" in attachment) {
|
||||
sha256 = attachment.sha256;
|
||||
|
||||
yield* db
|
||||
.insertInto("File")
|
||||
.values({ sha256, data: attachment.data })
|
||||
.onConflict((cb) => cb.column("sha256").doNothing())
|
||||
.$call(Database.execute);
|
||||
yield* db
|
||||
.selectFrom("File")
|
||||
.select("sha256")
|
||||
.where("sha256", "=", sha256)
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
} else {
|
||||
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(attachment.data, sha256);
|
||||
|
||||
yield* db
|
||||
.insertInto("File")
|
||||
.values({ sha256, data: attachment.data })
|
||||
.onConflict((cb) => cb.column("sha256").doNothing())
|
||||
.$call(Database.execute);
|
||||
}
|
||||
|
||||
const res = yield* db
|
||||
.insertInto("Attachment")
|
||||
|
||||
@@ -169,12 +169,17 @@ export default Api.bundle({
|
||||
// --- Attachment CRUD ---
|
||||
|
||||
createAttachment: Api.make(
|
||||
Schema.Struct({
|
||||
pieceId: PieceId,
|
||||
filename: Schema.NonEmptyString,
|
||||
mediaType: Schema.NonEmptyString,
|
||||
data: Uint8ArrayArrayBufferFromSelf,
|
||||
}),
|
||||
pipe(
|
||||
Schema.Union(
|
||||
Schema.Struct({ data: Uint8ArrayArrayBufferFromSelf }),
|
||||
Schema.Struct({ sha256: Sha256 }),
|
||||
),
|
||||
Schema.extend(Schema.Struct({
|
||||
pieceId: PieceId,
|
||||
filename: Schema.NonEmptyString,
|
||||
mediaType: Schema.NonEmptyString,
|
||||
})),
|
||||
),
|
||||
Attachment,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
|
||||
153
packages/frontend/src/components/PieceDialog.tsx
Normal file
153
packages/frontend/src/components/PieceDialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { pieceCache } from "@/cache";
|
||||
import { client } from "@/client";
|
||||
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useCache } from "@/hooks/useCache";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { setPieceQueryAuthor, setPieceQueryName, useStore } from "@/hooks/useStore";
|
||||
import { authors, DEBOUNCE } from "@/snippets";
|
||||
import { PieceId } from "common";
|
||||
import { Cause, Effect, Match, Option } from "effect";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ReactNode, useCallback, useRef } from "react";
|
||||
|
||||
const ADD_ENTRY_DIALOG_LIMIT = 10;
|
||||
|
||||
export namespace PieceDialogContent {
|
||||
export interface Props {
|
||||
readonly title: ReactNode;
|
||||
readonly onPiece: (pieceId: PieceId) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export function PieceDialogContent(props: PieceDialogContent.Props) {
|
||||
|
||||
const name = useStore(state => state.pieceQueryName);
|
||||
const author = useStore(state => state.pieceQueryAuthor);
|
||||
|
||||
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: ADD_ENTRY_DIALOG_LIMIT,
|
||||
});
|
||||
|
||||
return data;
|
||||
}), [name, author]);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row gap-4">
|
||||
<Input
|
||||
className="grow"
|
||||
type="text"
|
||||
placeholder="Tytuł"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setPieceQueryName(e.target.value);
|
||||
debounce.current = DEBOUNCE;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="grow"
|
||||
type="text"
|
||||
placeholder="Twórcy"
|
||||
value={author}
|
||||
onChange={(e) => {
|
||||
setPieceQueryAuthor(e.target.value);
|
||||
debounce.current = DEBOUNCE;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Table className="overflow-y-hidden">
|
||||
<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) => (
|
||||
<EntryDialogPieceRow
|
||||
key={pieceId}
|
||||
pieceId={pieceId}
|
||||
onPiece={props.onPiece}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
namespace EntryDialogPieceRow {
|
||||
export interface Props {
|
||||
readonly pieceId: PieceId;
|
||||
readonly onPiece: (pieceId: PieceId) => void;
|
||||
}
|
||||
}
|
||||
|
||||
function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
|
||||
|
||||
const { isLoading, error, data: piece } = useCache(pieceCache, props.pieceId);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
props.onPiece(props.pieceId);
|
||||
}, [props.pieceId, props.onPiece]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>Ładowanie…</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
Wystąpił błąd: {Match.value(error).pipe(
|
||||
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
|
||||
Match.tag("NotFound", () => "Repertuar nie istnieje"),
|
||||
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
|
||||
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
|
||||
Match.exhaustive,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow className="select-none cursor-pointer" onClick={onClick}>
|
||||
<TableCell>
|
||||
{piece.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{authors(piece)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,28 @@ export const mapProp = <const K extends string, T>(prop: K, action: Update<T>) =
|
||||
|
||||
export interface Store {
|
||||
readonly user: User | null;
|
||||
readonly pieceQueryName: string;
|
||||
readonly pieceQueryAuthor: string;
|
||||
}
|
||||
|
||||
let store: Store = Object.freeze<Store>({
|
||||
user: null,
|
||||
pieceQueryName: "",
|
||||
pieceQueryAuthor: "",
|
||||
});
|
||||
|
||||
export function setUser(action: Update<User | null>) {
|
||||
set(mapProp("user", action));
|
||||
}
|
||||
|
||||
export function setPieceQueryName(action: Update<string>) {
|
||||
set(mapProp("pieceQueryName", action));
|
||||
}
|
||||
|
||||
export function setPieceQueryAuthor(action: Update<string>) {
|
||||
set(mapProp("pieceQueryAuthor", action));
|
||||
}
|
||||
|
||||
// --- STORE IMPLEMENTATION ----------------------------------------------------
|
||||
|
||||
class Listener<T> {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Attachment, denormalizePiece, denormalizeSystemInformation, pieceCache, type Piece } from "@/cache";
|
||||
import { API_URL_PREFIX, client } from "@/client";
|
||||
import { client } from "@/client";
|
||||
import { PieceDialogContent } from "@/components/PieceDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useCache } from "@/hooks/useCache";
|
||||
@@ -13,7 +16,7 @@ import * as Body from "common/Body";
|
||||
import { getMediaTypeForFilename } from "common/MediaType";
|
||||
import { Array, Cause, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import { Download, Loader2, Trash, UploadCloud } from "lucide-react";
|
||||
import { Download, EllipsisVertical, Loader2, Trash, UploadCloud } from "lucide-react";
|
||||
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -208,6 +211,8 @@ namespace AttachmentRow {
|
||||
|
||||
function AttachmentRow(props: AttachmentRow.Props) {
|
||||
|
||||
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
|
||||
|
||||
const download = () => Effect.gen(function* () {
|
||||
const { data, mediaType, filename } = yield* client.getAttachment(props.attachment.attachmentId);
|
||||
|
||||
@@ -248,7 +253,28 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
||||
yield* pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter<Attachment>((a) => a.attachmentId !== props.attachment.attachmentId)));
|
||||
}).pipe(Effect.runPromise);
|
||||
|
||||
return (
|
||||
const onCloneToPiece = (pieceId: PieceId) => Effect.gen(function* () {
|
||||
|
||||
const attachment = yield* pipe(
|
||||
client.createAttachment({
|
||||
pieceId,
|
||||
filename: props.attachment.filename,
|
||||
mediaType: props.attachment.mediaType,
|
||||
sha256: props.attachment.sha256,
|
||||
}),
|
||||
Effect.flatMap(denormalizeSystemInformation)
|
||||
);
|
||||
|
||||
yield* pieceCache.update(pieceId, mapProp("attachments", (attachments: readonly Attachment[]) => {
|
||||
const next = [...attachments, attachment];
|
||||
next.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
return next;
|
||||
}));
|
||||
|
||||
setCloneDialogOpen(false);
|
||||
}).pipe(Effect.runPromise);
|
||||
|
||||
return (<>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
{props.attachment.mediaType === "application/vnd.recordare.musicxml"
|
||||
@@ -277,9 +303,27 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
||||
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={doDelete}>
|
||||
<Trash />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" title="Więcej">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setCloneDialogOpen(true)}>
|
||||
Kopiuj do…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
<Dialog open={cloneDialogOpen} onOpenChange={setCloneDialogOpen}>
|
||||
<PieceDialogContent
|
||||
title="Kopiuj do utworu"
|
||||
onPiece={onCloneToPiece}
|
||||
/>
|
||||
</Dialog>
|
||||
</>);
|
||||
}
|
||||
|
||||
namespace AttachmentForm {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { denormalizeRepertoire, Piece, pieceCache, type Repertoire, repertoireCache } from "@/cache";
|
||||
import { denormalizeRepertoire, Piece, type Repertoire, repertoireCache } from "@/cache";
|
||||
import { client } from "@/client";
|
||||
import { PieceDialogContent } from "@/components/PieceDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Dialog, 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, DEBOUNCE, SAVE_DELAY } from "@/snippets";
|
||||
import { PieceId, RepertoireId, Updater } from "common";
|
||||
import { Array, Cause, Effect, Fiber, Match, Option, pipe, Scope } from "effect";
|
||||
import { authors, SAVE_DELAY } from "@/snippets";
|
||||
import { PieceId, RepertoireId } from "common";
|
||||
import { Array, Cause, Effect, Fiber, Option, pipe, Scope } from "effect";
|
||||
import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
|
||||
import { FormEventHandler, useId, useRef, useState } from "react";
|
||||
import { FormEventHandler, useId, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
export function Repertoire() {
|
||||
@@ -135,6 +135,24 @@ function Entries(props: Entries.Props) {
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const onPiece = (pieceId: PieceId) => Effect.gen(function* () {
|
||||
|
||||
const action = Array.append(pieceId);
|
||||
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
||||
|
||||
yield* pipe(
|
||||
client.updateRepertoire({
|
||||
repertoireId: props.repertoire.repertoireId,
|
||||
name: props.repertoire.name,
|
||||
entries: pipe(props.repertoire.entries, mapToId, action),
|
||||
}),
|
||||
Effect.flatMap(denormalizeRepertoire),
|
||||
Effect.tap((repertoire) => repertoireCache.update(repertoire.repertoireId, repertoire)),
|
||||
);
|
||||
|
||||
setDialogOpen(false);
|
||||
}).pipe(Effect.runPromise);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-start grow">
|
||||
<Table>
|
||||
@@ -163,7 +181,10 @@ function Entries(props: Entries.Props) {
|
||||
<Plus />Dodaj utwór
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<AddEntryDialogContent repertoire={props.repertoire} setDialogOpen={setDialogOpen} />
|
||||
<PieceDialogContent
|
||||
title="Dodaj utwór"
|
||||
onPiece={onPiece}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
@@ -248,159 +269,3 @@ function EntryRow({
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const ADD_ENTRY_DIALOG_LIMIT = 10;
|
||||
|
||||
namespace AddEntryDialogContent {
|
||||
export interface Props {
|
||||
readonly repertoire: Repertoire;
|
||||
readonly setDialogOpen: Updater<boolean>;
|
||||
}
|
||||
}
|
||||
|
||||
function AddEntryDialogContent(props: AddEntryDialogContent.Props) {
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
|
||||
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: ADD_ENTRY_DIALOG_LIMIT,
|
||||
});
|
||||
|
||||
return data;
|
||||
}), [name, author]);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dodaj utwór</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row gap-4">
|
||||
<Input
|
||||
className="grow"
|
||||
type="text"
|
||||
placeholder="Tytuł"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
debounce.current = DEBOUNCE;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="grow"
|
||||
type="text"
|
||||
placeholder="Twórcy"
|
||||
value={author}
|
||||
onChange={(e) => {
|
||||
setAuthor(e.target.value);
|
||||
debounce.current = DEBOUNCE;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Table className="overflow-y-hidden">
|
||||
<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) => (
|
||||
<EntryDialogPieceRow
|
||||
key={pieceId}
|
||||
repertoire={props.repertoire}
|
||||
pieceId={pieceId}
|
||||
setDialogOpen={props.setDialogOpen}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
namespace EntryDialogPieceRow {
|
||||
export interface Props {
|
||||
readonly repertoire: Repertoire;
|
||||
readonly pieceId: PieceId;
|
||||
readonly setDialogOpen: Updater<boolean>;
|
||||
}
|
||||
}
|
||||
|
||||
function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
|
||||
|
||||
const { isLoading, error, data: piece } = useCache(pieceCache, props.pieceId);
|
||||
|
||||
const onClick = () => Effect.gen(function* () {
|
||||
|
||||
const action = Array.append(piece!);
|
||||
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
||||
|
||||
yield* pipe(
|
||||
client.updateRepertoire({
|
||||
repertoireId: props.repertoire.repertoireId,
|
||||
name: props.repertoire.name,
|
||||
entries: pipe(props.repertoire.entries, action, mapToId),
|
||||
}),
|
||||
Effect.flatMap(denormalizeRepertoire),
|
||||
Effect.tap((repertoire) => repertoireCache.update(repertoire.repertoireId, repertoire)),
|
||||
);
|
||||
|
||||
props.setDialogOpen(false);
|
||||
}).pipe(Effect.runPromise);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>Ładowanie…</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
Wystąpił błąd: {Match.value(error).pipe(
|
||||
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
|
||||
Match.tag("NotFound", () => "Repertuar nie istnieje"),
|
||||
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
|
||||
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
|
||||
Match.exhaustive,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow className="select-none cursor-pointer" onClick={onClick}>
|
||||
<TableCell>
|
||||
{piece.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{authors(piece)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user