Add repertoire list page

This commit is contained in:
Szymon Nowakowski
2024-12-25 23:08:40 +01:00
parent b3ec1b9463
commit 86184e990b
3 changed files with 251 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
import { Home } from "@/routes/Home"; import { Home } from "@/routes/Home";
import { Login } from "@/routes/Login"; import { Login } from "@/routes/Login";
import { Piece } from "@/routes/Piece"; import { Piece } from "@/routes/Piece";
import { Repertoire } from "@/routes/Repertoire";
import { Root } from "@/routes/Root"; import { Root } from "@/routes/Root";
import { Settings } from "@/routes/Settings"; import { Settings } from "@/routes/Settings";
import { lazy, StrictMode } from "react"; import { lazy, StrictMode } from "react";
@@ -32,6 +33,10 @@ const router = createBrowserRouter([
}, },
], ],
}, },
{
path: "repertoire",
Component: Repertoire,
},
{ {
path: "settings", path: "settings",
Component: Settings, Component: Settings,

View File

@@ -1,5 +1,5 @@
import type * as Db from "backend/database"; 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 { Cache, Duration, Effect, Option, pipe } from "effect";
import { client, mapResponse } from "./client"; import { client, mapResponse } from "./client";
@@ -87,8 +87,36 @@ export const denormalizePiece = ({
Effect.flatMap(denormalizeSystemInformation), 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 UserSemaphore = Effect.unsafeMakeSemaphore(1);
const CacheSemaphore = Effect.unsafeMakeSemaphore(4); const CacheSemaphore = Effect.unsafeMakeSemaphore(4);
const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
export const userLookup = (userId: UserId) => pipe( export const userLookup = (userId: UserId) => pipe(
Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })), Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })),
@@ -103,6 +131,13 @@ export const pieceLookup = (pieceId: PieceId) => pipe(
CacheSemaphore.withPermits(1), 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({ export const userCache = Effect.runSync(Cache.make({
capacity: Infinity, capacity: Infinity,
timeToLive: Duration.days(1), timeToLive: Duration.days(1),
@@ -114,3 +149,9 @@ export const pieceCache = Effect.runSync(Cache.make({
timeToLive: Duration.days(1), timeToLive: Duration.days(1),
lookup: pieceLookup, lookup: pieceLookup,
})); }));
export const repertoireCache = Effect.runSync(Cache.make({
capacity: Infinity,
timeToLive: Duration.days(1),
lookup: repertoireLookup,
}));

View File

@@ -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 (
<div className="p-4 overflow-y-auto flex flex-col items-start gap-4">
<div className="flex flex-row gap-4">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Plus />Utwórz repertuar
</Button>
</DialogTrigger>
<AddRepertoireDialogContent />
</Dialog>
<Input
className="w-[32ch]"
type="text"
placeholder="Nazwa"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<Table>
<TableHeader className="bg-white sticky top-0">
<TableRow>
<TableHead>Nazwa</TableHead>
<TableHead>Utwory</TableHead>
<TableHead className="text-center">Dodano</TableHead>
<TableHead className="text-center">Zmodyfikowano</TableHead>
</TableRow>
</TableHeader>
<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: ${error.value}`}
</TableCell>
</TableRow>
) : (
repertoireIds.map((repertoireId) => <RepertoireRow key={repertoireId} repertoireId={repertoireId} />)
)}
</TableBody>
</Table>
</div>
);
}
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 (
<TableRow>
<TableCell colSpan={4}>Ładowanie</TableCell>
</TableRow>
);
}
if (error !== null) {
return (
<TableRow>
<TableCell colSpan={4}>Wystąpił błąd: {error.value}</TableCell>
</TableRow>
);
}
const piecesParts: ReactNode[] = [];
if (repertoire.entries.length === 0) {
piecesParts.push(<em>Brak</em>);
} 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 (
<TableRow>
<TableCell>
{repertoire.name}
</TableCell>
<TableCell>
{...piecesParts}
</TableCell>
<TableCell className="text-center text-xs">
{repertoire.createdAt}
{Option.isSome(repertoire.createdBy) && <><br />przez {repertoire.createdBy.value.username}</>}
</TableCell>
<TableCell className="text-center text-xs">
{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<User>).value.username}`
: <>{repertoire.modifiedAt.value}<br />przez {(repertoire.modifiedBy as Option.Some<User>).value.username}</>}
</TableCell>
</TableRow>
);
}
function AddRepertoireDialogContent() {
const [name, setName] = useState("");
const nameId = useId();
const [isLoading, setIsLoading] = useState(false);
const onSubmit: FormEventHandler<HTMLFormElement> = 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 (
<DialogContent>
<form onSubmit={onSubmit}>
<DialogHeader>
<DialogTitle>Utwórz repertuar</DialogTitle>
</DialogHeader>
<div className="grid items-baseline grid-cols-4 gap-4 py-4">
<Label htmlFor={nameId} className="text-right">Nazwa</Label>
<Input
id={nameId}
className="col-span-3"
type="text"
value={name}
autoFocus
required
onChange={(e) => setName(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Utwórz
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}