Add repertoire list page
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
|||||||
204
packages/frontend/src/routes/Repertoire.tsx
Normal file
204
packages/frontend/src/routes/Repertoire.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user