diff --git a/.vscode/settings.json b/.vscode/settings.json index 15b95b5..c022a4b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { "typescript.tsdk": "node_modules/typescript/lib", + "typescript.preferences.autoImportFileExcludePatterns": [ + "@radix-ui", + ], } diff --git a/packages/backend/src/the_api.ts b/packages/backend/src/the_api.ts index f90e784..aac700d 100644 --- a/packages/backend/src/the_api.ts +++ b/packages/backend/src/the_api.ts @@ -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") diff --git a/packages/common/src/the_api.ts b/packages/common/src/the_api.ts index 2d9ecf7..7e64adb 100644 --- a/packages/common/src/the_api.ts +++ b/packages/common/src/the_api.ts @@ -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), ), diff --git a/packages/frontend/src/components/PieceDialog.tsx b/packages/frontend/src/components/PieceDialog.tsx new file mode 100644 index 0000000..f16488c --- /dev/null +++ b/packages/frontend/src/components/PieceDialog.tsx @@ -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 ( + + + {props.title} + + + + { + setPieceQueryName(e.target.value); + debounce.current = DEBOUNCE; + }} + /> + { + setPieceQueryAuthor(e.target.value); + debounce.current = DEBOUNCE; + }} + /> + + + + {isLoading ? ( + + + + + Ładowanie… + + + + ) : error !== null ? ( + + + {Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`} + + + ) : ( + pieceIds.map((pieceId) => ( + + )) + )} + + + + + ); +} + +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 ( + + Ładowanie… + + ); + } + + if (error !== null) { + return ( + + + 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, + )} + + + ); + } + + return ( + + + {piece.name} + + + {authors(piece)} + + + ); +} diff --git a/packages/frontend/src/hooks/useStore.ts b/packages/frontend/src/hooks/useStore.ts index 40fe611..23ffa1a 100644 --- a/packages/frontend/src/hooks/useStore.ts +++ b/packages/frontend/src/hooks/useStore.ts @@ -9,16 +9,28 @@ export const mapProp = (prop: K, action: Update) = export interface Store { readonly user: User | null; + readonly pieceQueryName: string; + readonly pieceQueryAuthor: string; } let store: Store = Object.freeze({ user: null, + pieceQueryName: "", + pieceQueryAuthor: "", }); export function setUser(action: Update) { set(mapProp("user", action)); } +export function setPieceQueryName(action: Update) { + set(mapProp("pieceQueryName", action)); +} + +export function setPieceQueryAuthor(action: Update) { + set(mapProp("pieceQueryAuthor", action)); +} + // --- STORE IMPLEMENTATION ---------------------------------------------------- class Listener { diff --git a/packages/frontend/src/routes/Piece.tsx b/packages/frontend/src/routes/Piece.tsx index 9388a4b..2b1d426 100644 --- a/packages/frontend/src/routes/Piece.tsx +++ b/packages/frontend/src/routes/Piece.tsx @@ -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((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 (<> {props.attachment.mediaType === "application/vnd.recordare.musicxml" @@ -277,9 +303,27 @@ function AttachmentRow(props: AttachmentRow.Props) { + + + + + + + + setCloneDialogOpen(true)}> + Kopiuj do… + + + - ); + + + + >); } namespace AttachmentForm { diff --git a/packages/frontend/src/routes/Repertoire.tsx b/packages/frontend/src/routes/Repertoire.tsx index 37ddeb5..5d6e7a8 100644 --- a/packages/frontend/src/routes/Repertoire.tsx +++ b/packages/frontend/src/routes/Repertoire.tsx @@ -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(({ 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 ( @@ -163,7 +181,10 @@ function Entries(props: Entries.Props) { Dodaj utwór - + ); @@ -248,159 +269,3 @@ function EntryRow({ ); } - -const ADD_ENTRY_DIALOG_LIMIT = 10; - -namespace AddEntryDialogContent { - export interface Props { - readonly repertoire: Repertoire; - readonly setDialogOpen: Updater; - } -} - -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 ( - - - Dodaj utwór - - - - { - setName(e.target.value); - debounce.current = DEBOUNCE; - }} - /> - { - setAuthor(e.target.value); - debounce.current = DEBOUNCE; - }} - /> - - - - {isLoading ? ( - - - - - Ładowanie… - - - - ) : error !== null ? ( - - - {Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`} - - - ) : ( - pieceIds.map((pieceId) => ( - - )) - )} - - - - - ); -} - -namespace EntryDialogPieceRow { - export interface Props { - readonly repertoire: Repertoire; - readonly pieceId: PieceId; - readonly setDialogOpen: Updater; - } -} - -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(({ 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 ( - - Ładowanie… - - ); - } - - if (error !== null) { - return ( - - - 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, - )} - - - ); - } - - return ( - - - {piece.name} - - - {authors(piece)} - - - ); -}