diff --git a/packages/frontend/src/app.tsx b/packages/frontend/src/app.tsx index 4426f0d..37e8797 100644 --- a/packages/frontend/src/app.tsx +++ b/packages/frontend/src/app.tsx @@ -1,6 +1,7 @@ import { Home } from "@/routes/Home"; import { Login } from "@/routes/Login"; import { Piece } from "@/routes/Piece"; +import { Repertoire } from "@/routes/Repertoire"; import { Root } from "@/routes/Root"; import { Settings } from "@/routes/Settings"; import { lazy, StrictMode } from "react"; @@ -32,6 +33,10 @@ const router = createBrowserRouter([ }, ], }, + { + path: "repertoire", + Component: Repertoire, + }, { path: "settings", Component: Settings, diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index 729ba6d..33900d8 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -1,5 +1,5 @@ import type * as Db from "backend/database"; -import { AttachmentId, PieceId, UserId } from "common"; +import { AttachmentId, PieceId, RepertoireId, UserId } from "common"; import { Cache, Duration, Effect, Option, pipe } from "effect"; import { client, mapResponse } from "./client"; @@ -87,8 +87,36 @@ export const denormalizePiece = ({ Effect.flatMap(denormalizeSystemInformation), ); +export const denormalizeRepertoireEntry = ({ + pieceId, + ...rest +}: Db.RepertoireEntry) => pipe( + Effect.all({ + piece: Effect.uninterruptible(pieceCache.get(pieceId)), + }, { concurrency: "unbounded" }), + Effect.map((entry) => Object.freeze({ + ...rest, + ...entry, + })), +); + +export const denormalizeRepertoire = ({ + entries, + ...rest +}: Db.Repertoire & { entries: Db.RepertoireEntry[] }) => pipe( + Effect.all({ + entries: Effect.all(entries.map(denormalizeRepertoireEntry), { concurrency: "unbounded" }), + }, { concurrency: "unbounded" }), + Effect.map((repertoire) => Object.freeze({ + ...rest, + ...repertoire, + })), + Effect.flatMap(denormalizeSystemInformation), +); + const UserSemaphore = Effect.unsafeMakeSemaphore(1); const CacheSemaphore = Effect.unsafeMakeSemaphore(4); +const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1); export const userLookup = (userId: UserId) => pipe( Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })), @@ -103,6 +131,13 @@ export const pieceLookup = (pieceId: PieceId) => pipe( CacheSemaphore.withPermits(1), ); +export const repertoireLookup = (repertoireId: RepertoireId) => pipe( + Effect.promise((signal) => client.repertoire({ repertoireId }).get({ fetch: { signal } })), + Effect.flatMap(mapResponse), + Effect.flatMap(denormalizeRepertoire), + RepertoireSemaphore.withPermits(1), +); + export const userCache = Effect.runSync(Cache.make({ capacity: Infinity, timeToLive: Duration.days(1), @@ -114,3 +149,9 @@ export const pieceCache = Effect.runSync(Cache.make({ timeToLive: Duration.days(1), lookup: pieceLookup, })); + +export const repertoireCache = Effect.runSync(Cache.make({ + capacity: Infinity, + timeToLive: Duration.days(1), + lookup: repertoireLookup, +})); diff --git a/packages/frontend/src/routes/Repertoire.tsx b/packages/frontend/src/routes/Repertoire.tsx new file mode 100644 index 0000000..8db41ac --- /dev/null +++ b/packages/frontend/src/routes/Repertoire.tsx @@ -0,0 +1,204 @@ +import { repertoireCache, User } 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 { useLoadingEffect } from "@/hooks/useLoading"; +import { RepertoireId } from "common"; +import { Cause, Clock, Duration, Effect, Option } from "effect"; +import { Loader2, Plus } from "lucide-react"; +import { FormEventHandler, ReactNode, useId, useState } from "react"; + +export function Repertoire() { + + const [name, setName] = useState(""); + + const { isLoading, error, data: repertoireIds } = useLoadingEffect(Effect.gen(function* () { + yield* Clock.sleep(Duration.millis(500)); + const { error, data } = yield* Effect.promise((signal) => client.repertoire.get({ + query: { + ...(name !== "" ? { name } : undefined), + }, + fetch: { signal }, + })); + + if (error !== null) { + return yield* Effect.fail(error); + } else { + return data; + } + }), [name]); + + return ( +
+
+ + + + + + + setName(e.target.value)} + /> +
+ + + + Nazwa + Utwory + Dodano + Zmodyfikowano + + + + {isLoading ? ( + + +
+ + Ładowanie… +
+
+
+ ) : error !== null ? ( + + + {Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}`} + + + ) : ( + repertoireIds.map((repertoireId) => ) + )} +
+
+
+ ); +} + +namespace RepertoireRow { + export interface Props { + readonly repertoireId: RepertoireId; + } +} + +function RepertoireRow(props: RepertoireRow.Props) { + + const { isLoading, error, data: repertoire } = useLoadingEffect(Effect.uninterruptible(repertoireCache.get(props.repertoireId)), [props.repertoireId]); + + if (isLoading) { + return ( + + Ładowanie… + + ); + } + + if (error !== null) { + return ( + + Wystąpił błąd: {error.value} + + ); + } + + const piecesParts: ReactNode[] = []; + + if (repertoire.entries.length === 0) { + piecesParts.push(Brak); + } else { + const CUTOFF = 10; + let text = repertoire.entries.slice(0, CUTOFF).map(({ piece: { name } }) => name).join(", "); + if (repertoire.entries.length > CUTOFF) { + text += ", …"; + } + + piecesParts.push(text); + } + + return ( + + + {repertoire.name} + + + {...piecesParts} + + + {repertoire.createdAt} + {Option.isSome(repertoire.createdBy) && <>
przez {repertoire.createdBy.value.username}} +
+ + {Option.isNone(repertoire.modifiedAt) && Option.isNone(repertoire.modifiedBy) ? "\u2014" + : Option.isSome(repertoire.modifiedAt) && Option.isNone(repertoire.modifiedBy) ? repertoire.modifiedAt.value + : Option.isNone(repertoire.modifiedAt) ? `przez ${(repertoire.modifiedBy as Option.Some).value.username}` + : <>{repertoire.modifiedAt.value}
przez {(repertoire.modifiedBy as Option.Some).value.username}} +
+
+ ); +} + +function AddRepertoireDialogContent() { + + const [name, setName] = useState(""); + + const nameId = useId(); + + const [isLoading, setIsLoading] = useState(false); + + const onSubmit: FormEventHandler = async (e) => { + e.preventDefault(); + + try { + setIsLoading(true); + + const { data, error } = await client.repertoire.post({ + name, + entries: [], + }); + + if (error !== null) { + console.error(error.value); + return; + } + } finally { + setIsLoading(false); + } + }; + + return ( + +
+ + Utwórz repertuar + +
+ + setName(e.target.value)} + /> +
+ + + +
+
+ ); +}