Clone attachment to other piece

This commit is contained in:
2025-10-15 15:06:41 +02:00
parent 8c05c0e825
commit 589994d62f
7 changed files with 275 additions and 181 deletions

View File

@@ -1,3 +1,6 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.autoImportFileExcludePatterns": [
"@radix-ui",
],
}

View File

@@ -316,7 +316,18 @@ 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));
let sha256: Sha256;
if ("sha256" in attachment) {
sha256 = attachment.sha256;
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
@@ -324,6 +335,7 @@ export const handle = implement(api, {
.values({ sha256, data: attachment.data })
.onConflict((cb) => cb.column("sha256").doNothing())
.$call(Database.execute);
}
const res = yield* db
.insertInto("Attachment")

View File

@@ -169,12 +169,17 @@ export default Api.bundle({
// --- Attachment CRUD ---
createAttachment: Api.make(
Schema.Struct({
pipe(
Schema.Union(
Schema.Struct({ data: Uint8ArrayArrayBufferFromSelf }),
Schema.Struct({ sha256: Sha256 }),
),
Schema.extend(Schema.Struct({
pieceId: PieceId,
filename: Schema.NonEmptyString,
mediaType: Schema.NonEmptyString,
data: Uint8ArrayArrayBufferFromSelf,
}),
})),
),
Attachment,
Schema.Union(Unauthenticated, Unauthorized, NotFound),
),

View 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>
);
}

View File

@@ -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> {

View File

@@ -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 {

View File

@@ -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>
);
}