diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index f0d34ee..92b893f 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -802,11 +802,7 @@ const app = new Elysia() const res = await q.execute(); return res.map(({ repertoireId }) => repertoireId); }, { - query: t.Object({ - name: t.Optional(t.String()), - offset: t.Optional(t.Integer({ minimum: 0 })), - limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), - }), + query: Model.Repertoire_Query, response: { 200: t.Array(Model.RepertoireId), 401: t.Literal("Session invalid or expired"), diff --git a/packages/backend/src/model.ts b/packages/backend/src/model.ts index 46cc05b..a0136a4 100644 --- a/packages/backend/src/model.ts +++ b/packages/backend/src/model.ts @@ -22,8 +22,8 @@ const SystemInformation = Object.freeze({ }); const Pagination = Object.freeze({ - offset: t.Optional(t.Integer({ minimum: 0 })), - limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), + offset: t.Optional(t.Numeric({ minimum: 0 })), + limit: t.Optional(t.Numeric({ minimum: 1, maximum: 100 })), }); export const AccessLog = t.Object({ @@ -74,6 +74,11 @@ export const Repertoire = t.Object({ ...SystemInformation, }); +export const Repertoire_Query = t.Object({ + name: t.Optional(t.String()), + ...Pagination, +}); + export const User = t.Object({ userId: UserId, username: t.String(), @@ -103,6 +108,7 @@ export type Piece = typeof Piece.static; export type Piece_Post = typeof Piece_Post.static; export type Piece_Query = typeof Piece_Query.static; export type Repertoire = typeof Repertoire.static; +export type Repertoire_Query = typeof Repertoire_Query.static; export type User = typeof User.static; export type User_Patch = typeof User_Patch.static; export type User_Post = typeof User_Post.static; diff --git a/packages/frontend/src/routes/Repertoire.tsx b/packages/frontend/src/routes/Repertoire.tsx index 2a07c06..7a8686c 100644 --- a/packages/frontend/src/routes/Repertoire.tsx +++ b/packages/frontend/src/routes/Repertoire.tsx @@ -1,17 +1,18 @@ -import { Piece, type Repertoire, repertoireCache } from "@/cache"; +import { Piece, pieceCache, type Repertoire, repertoireCache } from "@/cache"; import { client } from "@/client"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, 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 { useLoadingEffect } from "@/hooks/useLoading"; import { mapProp, Update, Updater } from "@/hooks/useStore"; -import { authors, saveDelay } from "@/snippets"; +import { authors, DEBOUNCE, saveDelay } from "@/snippets"; import { PieceId, RepertoireId } from "common"; -import { Array, Cause, Effect, pipe } from "effect"; -import { CircleMinus, Loader2 } from "lucide-react"; -import { FormEventHandler, useCallback, useId, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { Array, Cause, Effect, Option, pipe } from "effect"; +import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react"; +import { FormEventHandler, useCallback, useId, useMemo, useRef, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; export function Repertoire() { @@ -141,12 +142,15 @@ namespace Entries { } function Entries(props: Entries.Props) { + + const [dialogOpen, setDialogOpen] = useState(false); + return ( -
+
- Lp. + Lp. Tytuł Twórcy Akcje @@ -164,6 +168,14 @@ function Entries(props: Entries.Props) { ))}
+ + + + + +
); } @@ -177,18 +189,41 @@ namespace EntryRow { } } -function EntryRow(props: EntryRow.Props) { +function EntryRow({ + repertoire, + piece, + no, + setEntries, +}: EntryRow.Props) { - const remove = useCallback(async () => { + const moveUpAction = useCallback((entries: readonly Piece[]) => pipe( + entries, + Array.remove(no - 1), + Array.insertAt(no - 2, piece), + Option.getOrThrow, + ), [no, piece]); + + const moveDownAction = useCallback((entries: readonly Piece[]) => pipe( + entries, + Array.remove(no - 1), + Array.insertAt(no, piece), + Option.getOrThrow, + ), [no, piece]); + + const removeAction = useCallback((entries: readonly Piece[]) => pipe( + entries, + Array.filter((p) => p.pieceId !== piece.pieceId), + ), [piece.pieceId]); + + const update = useCallback(async (action: (prev: readonly Piece[]) => readonly Piece[]) => { - const filter = Array.filter((piece) => piece.pieceId !== props.piece.pieceId); const mapToId = Array.map(({ pieceId }) => pieceId); const { error } = await client - .repertoire({ repertoireId: props.repertoire.repertoireId }) + .repertoire({ repertoireId: repertoire.repertoireId }) .put({ - name: props.repertoire.name, - entries: pipe(props.repertoire.entries, filter, mapToId), + name: repertoire.name, + entries: pipe(repertoire.entries, action, mapToId), }); if (error !== null) { @@ -196,22 +231,33 @@ function EntryRow(props: EntryRow.Props) { return; } - props.setEntries(filter); + setEntries(action); + }, [repertoire.entries, repertoire.name, repertoire.repertoireId, setEntries]); - }, [props]); + const moveUp = useMemo(() => update.bind(undefined, moveUpAction), [moveUpAction, update]); + const moveDown = useMemo(() => update.bind(undefined, moveDownAction), [moveDownAction, update]); + const remove = useMemo(() => update.bind(undefined, removeAction), [removeAction, update]); return ( - - {props.no} + + {no} - {props.piece.name} + + {piece.name} + - {authors(props.piece)} + {authors(piece)} + + @@ -219,3 +265,164 @@ function EntryRow(props: EntryRow.Props) { ); } + +const ADD_ENTRY_DIALOG_LIMIT = 10; + +namespace AddEntryDialogContent { + export interface Props { + readonly repertoire: Repertoire; + readonly setEntries: Updater; + 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 } = useLoadingEffect(Effect.gen(function* () { + yield* debounce.current; + const { error, data } = yield* Effect.promise((signal) => client.piece.get({ + query: { + ...(name !== "" ? { name } : undefined), + ...(author !== "" ? { author } : undefined), + limit: ADD_ENTRY_DIALOG_LIMIT, + }, + fetch: { signal }, + })); + + if (error !== null) { + return yield* Effect.fail(error); + } else { + 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: ${error.value}`} + + + ) : ( + pieceIds.map((pieceId) => ( + + )) + )} +
+
+
+
+ ); +} + +namespace EntryDialogPieceRow { + export interface Props { + readonly repertoire: Repertoire; + readonly pieceId: PieceId; + readonly setEntries: Updater; + readonly setDialogOpen: Updater; + } +} + +function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) { + + const { isLoading, error, data: piece } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]); + + const onClick = useCallback(async () => { + + const action = Array.append(piece!); + const mapToId = Array.map(({ pieceId }) => pieceId); + + const { error } = await client + .repertoire({ repertoireId: props.repertoire.repertoireId }) + .put({ + name: props.repertoire.name, + entries: pipe(props.repertoire.entries, action, mapToId), + }); + + if (error !== null) { + console.error(error.value); + return; + } + + props.setEntries(action); + props.setDialogOpen(false); + + }, [piece, props]); + + if (isLoading) { + return ( + + Ładowanie… + + ); + } + + if (error !== null) { + return ( + + Wystąpił błąd: {error.value} + + ); + } + + return ( + + + {piece.name} + + + {authors(piece)} + + + ); +}