Add effect because I coldn't resist

This commit is contained in:
2024-12-17 20:29:52 +01:00
parent 80c574dfd6
commit 555a24dbe5
12 changed files with 445 additions and 119 deletions

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-slot": "catalog:",
"clsx": "catalog:",
"common": "workspace:^",
"effect": "catalog:",
"lucide-react": "catalog:",
"opensheetmusicdisplay": "catalog:",
"react": "catalog:",

View File

@@ -0,0 +1,111 @@
import type * as Db from "backend/database";
import { AttachmentId, PieceId, UserId } from "common";
import { Cache, Duration, Effect, Option, pipe } from "effect";
import { client, mapResponse } from "./client";
export interface User {
readonly userId: UserId;
readonly username: string;
readonly admin: boolean;
}
export interface SystemInformation {
readonly createdBy: Option.Option<User>;
readonly createdAt: string;
readonly modifiedBy: Option.Option<User>;
readonly modifiedAt: Option.Option<string>;
}
export interface Attachment extends SystemInformation {
readonly attachmentId: AttachmentId;
readonly pieceId: PieceId;
readonly sha256: string;
readonly filename: string;
readonly mediaType: string;
}
export interface Piece extends SystemInformation {
readonly pieceId: PieceId;
readonly name: string;
readonly composer: Option.Option<string>;
readonly lyricist: Option.Option<string>;
readonly arranger: Option.Option<string>;
readonly attachments: readonly Attachment[];
}
interface DbSystemInformation {
readonly createdBy: UserId | null;
readonly createdAt: string;
readonly modifiedBy: UserId | null;
readonly modifiedAt: string | null;
}
export const denormalizeSystemInformation = <T extends DbSystemInformation>({
createdBy,
modifiedBy,
modifiedAt,
...rest
}: T) => pipe(
Effect.all({
createdBy: pipe(
createdBy,
Effect.fromNullable,
Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))),
Effect.optionFromOptional,
),
modifiedBy: pipe(
modifiedBy,
Effect.fromNullable,
Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))),
Effect.optionFromOptional,
),
}, { concurrency: "unbounded" }),
Effect.map((si) => Object.freeze({
...rest,
...si,
modifiedAt: Option.fromNullable(modifiedAt),
})),
);
export const denormalizePiece = ({
composer,
lyricist,
arranger,
attachments,
...rest
}: Db.Piece & { attachments: (Omit<Db.Attachment, "sha256"> & { sha256: string })[] }) => pipe(
Effect.all({
attachments: Effect.all(attachments.map(denormalizeSystemInformation), { concurrency: "unbounded" }),
}, { concurrency: "unbounded" }),
Effect.map((piece) => Object.freeze({
...rest,
...piece,
composer: Option.fromNullable(composer),
lyricist: Option.fromNullable(lyricist),
arranger: Option.fromNullable(arranger),
})),
Effect.flatMap(denormalizeSystemInformation),
);
export const userLookup = (userId: UserId) => pipe(
Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })),
Effect.flatMap(mapResponse),
);
export const pieceLookup = (pieceId: PieceId) => pipe(
Effect.promise((signal) => client.piece({ pieceId }).get({ fetch: { signal } })),
Effect.flatMap(mapResponse),
Effect.flatMap(denormalizePiece),
);
export const userCache = Effect.runSync(Cache.make({
capacity: Infinity,
timeToLive: Duration.infinity,
lookup: userLookup,
}));
export const pieceCache = Effect.runSync(Cache.make({
capacity: Infinity,
timeToLive: Infinity,
lookup: pieceLookup,
}));

View File

@@ -1,4 +1,9 @@
import { treaty } from "@elysiajs/eden";
import { Treaty, treaty } from "@elysiajs/eden";
import type { App } from "backend/app";
import { Effect } from "effect";
export type ResponseEffect<R extends Record<number, unknown>> = Effect.Effect<R[200], Exclude<keyof R, 200> extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200>]>;
export const client = treaty<App>("localhost:3000", { fetch: { credentials: "include" } });
export const mapResponse = <R extends Record<number, unknown>>({ error, data }: Treaty.TreatyResponse<R>): ResponseEffect<R> => error !== null ? Effect.fail(error) as any : Effect.succeed(data);

View File

@@ -92,7 +92,7 @@ const DropdownMenuCheckboxItem = forwardRef<
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-stone-100 focus:text-stone-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-stone-800 dark:focus:text-stone-50",
className
)}
checked={checked}
{...(checked !== undefined) ? { checked } : undefined}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">

View File

@@ -1,11 +1,9 @@
import { mapProp, Updater, useStore } from "@/hooks/useStore";
import { Treaty } from "@elysiajs/eden";
import { ELYSIA_FORM_DATA } from "elysia";
import { Effect, Fiber, pipe } from "effect";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
type FormDataFix<T> = T extends { [ELYSIA_FORM_DATA]: infer U } ? U : T;
export type LoadingResult<R extends Record<number, unknown>> =
| {
isLoading: true,
@@ -13,9 +11,9 @@ export type LoadingResult<R extends Record<number, unknown>> =
error: null,
} | {
isLoading: false,
data: FormDataFix<R[200]>,
data: R[200],
error: null,
setData: Updater<FormDataFix<R[200]>>,
setData: Updater<R[200]>,
} | {
isLoading: false,
data: null,
@@ -25,7 +23,7 @@ export type LoadingResult<R extends Record<number, unknown>> =
}
;
export function useLoading<R extends Record<number, unknown>>(fn: () => Promise<Treaty.TreatyResponse<R>>) {
export function useLoading<R extends Record<number, unknown>>(fn: () => Promise<Treaty.TreatyResponse<R>>, deps: React.DependencyList) {
const navigate = useNavigate();
@@ -56,6 +54,69 @@ export function useLoading<R extends Record<number, unknown>>(fn: () => Promise<
});
return () => { cancelled = true; };
}, deps);
return result;
}
const IS_LOADING = Object.freeze({
isLoading: true,
data: null,
error: null,
setData: null,
});
export type LoadingEffectResult<A, E> =
| {
readonly isLoading: true,
readonly data: null,
readonly error: null,
readonly setData: null,
} | {
readonly isLoading: false,
readonly data: A,
readonly error: null,
readonly setData: Updater<A>,
} | {
readonly isLoading: false,
readonly data: null,
readonly error: E,
readonly setData: null,
}
;
function mapSuccess<A>(data: A) {
return Object.freeze({ isLoading: false as const, data, error: null });
}
function mapFailure<E>(error: E) {
return Object.freeze({ isLoading: false as const, data: null, error });
}
export function useLoadingEffect<A, E>(effect: Effect.Effect<A, E>) {
const [result, setResult] = useState<LoadingEffectResult<A, E>>(IS_LOADING);
useEffect(() => {
const fiber = pipe(
effect,
Effect.match({
onSuccess: mapSuccess,
onFailure: mapFailure,
}),
Effect.tap((result) => {
setResult({
...result,
setData: ((action: any) => (setResult as any)(mapProp("data", action))) as any,
});
}),
Effect.runFork,
);
const interruptEffect = Fiber.interrupt(fiber);
return () => {
Effect.runFork(interruptEffect);
};
}, []);
return result;

View File

@@ -11,14 +11,14 @@ export default function Attachment() {
const pieceId = PieceId(params.pieceId!);
const attachmentId = AttachmentId(params.attachmentId!);
const { isLoading, error, data } = useLoading(() => client.piece({ pieceId }).attachment({ attachmentId }).get());
const { isLoading, error, data } = useLoading(() => client.piece({ pieceId }).attachment({ attachmentId }).get(), [pieceId, attachmentId]);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isLoading || error !== null) return;
const url = URL.createObjectURL(data?.data);
const url = URL.createObjectURL(data);
const render = () => osmd.render();

View File

@@ -1,17 +1,28 @@
import { pieceCache, 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 { useLoading } from "@/hooks/useLoading";
import { useLoading, useLoadingEffect } from "@/hooks/useLoading";
import { PieceId } from "common";
import { Cause, Effect, Option } from "effect";
import { Loader2, Plus } from "lucide-react";
import { FormEventHandler, ReactNode, useId, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
export function Home() {
const { isLoading, error, data } = useLoading(() => client.piece.get({ query: {} }));
const [name, setName] = useState("");
const [author, setAuthor] = useState("");
const { isLoading, error, data: pieceIds } = useLoading(() => client.piece.get({
query: {
...(name !== "" ? { name } : undefined),
...(author !== "" ? { author } : undefined),
},
}), [name, author]);
if (isLoading) {
return (
@@ -24,16 +35,32 @@ export function Home() {
return (
<div className="p-4 overflow-y-auto flex flex-col items-start gap-4">
{error !== null ? (
`Wystąpił błąd: ${error.value}`
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}`
) : (<>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Plus />Dodaj utwór
</Button>
</DialogTrigger>
<AddPieceDialogContent />
</Dialog>
<div className="flex flex-row gap-4">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Plus />Dodaj utwór
</Button>
</DialogTrigger>
<AddPieceDialogContent />
</Dialog>
<Input
className="w-[32ch]"
type="text"
placeholder="Tytuł"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
className="w-[32ch]"
type="text"
placeholder="Autor"
value={author}
onChange={(e) => setAuthor(e.target.value)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
@@ -44,29 +71,7 @@ export function Home() {
</TableRow>
</TableHeader>
<TableBody>
{data.map((piece) => {
const composerParts: ReactNode[] = [];
if (piece.composer !== null) composerParts.push(piece.composer);
if (piece.arranger !== null) composerParts.push(`opracowanie: ${piece.arranger}`);
if (piece.lyricist !== null) composerParts.push(`słowa: ${piece.lyricist}`);
if (composerParts.length === 0) composerParts.push(<em>Nieznani</em>);
return (
<TableRow key={piece.pieceId}>
<TableCell><Link className="underline" to={`piece/${piece.pieceId}`}>{piece.name}</Link></TableCell>
<TableCell>{...composerParts.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x])}</TableCell>
<TableCell className="text-center font-mono text-xs">
{piece.createdAt}
{piece.createdBy !== null && <><br />przez {piece.createdBy}</>}
</TableCell>
<TableCell className="text-center font-mono text-xs">
{piece.modifiedAt === null && piece.modifiedBy === null ? "\u2014"
: piece.modifiedAt !== null && piece.modifiedBy === null ? piece.modifiedAt
: piece.modifiedAt === null ? `przez ${piece.createdBy}`
: <>{piece.createdAt}<br />przez {piece.createdBy}</>}
</TableCell>
</TableRow>
);
})}
{pieceIds.map((pieceId) => <PieceRow key={pieceId} pieceId={pieceId} />)}
</TableBody>
</Table>
</>)}
@@ -74,6 +79,61 @@ export function Home() {
);
}
namespace PieceRow {
export interface Props {
readonly pieceId: PieceId;
}
}
function PieceRow(props: PieceRow.Props) {
const { isLoading, error, data: piece } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(props.pieceId)));
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 composerParts: ReactNode[] = [];
if (Option.isSome(piece.composer)) composerParts.push(piece.composer.value);
if (Option.isSome(piece.arranger)) composerParts.push(`opracowanie: ${piece.arranger.value}`);
if (Option.isSome(piece.lyricist)) composerParts.push(`słowa: ${piece.lyricist.value}`);
if (composerParts.length === 0) composerParts.push(<em>Nieznani</em>);
return (
<TableRow>
<TableCell>
<Link className="underline" to={`piece/${piece.pieceId}`}>{piece.name}</Link>
</TableCell>
<TableCell>
{...composerParts.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x])}
</TableCell>
<TableCell className="text-center text-xs">
{piece.createdAt}
{Option.isSome(piece.createdBy) && <><br />przez {piece.createdBy.value.username}</>}
</TableCell>
<TableCell className="text-center text-xs">
{Option.isNone(piece.modifiedAt) && Option.isNone(piece.modifiedBy) ? "\u2014"
: Option.isSome(piece.modifiedAt) && Option.isNone(piece.modifiedBy) ? piece.modifiedAt.value
: Option.isNone(piece.modifiedAt) ? `przez ${(piece.modifiedBy as Option.Some<User>).value.username}`
: <>{piece.modifiedAt.value}<br />przez {(piece.modifiedBy as Option.Some<User>).value.username}</>}
</TableCell>
</TableRow>
);
}
function AddPieceDialogContent() {
const navigate = useNavigate();

View File

@@ -36,7 +36,7 @@ export function Login() {
password: loginPassword,
});
if (error) {
if (error !== null) {
console.error(error.value);
return;
}

View File

@@ -1,16 +1,17 @@
import { Attachment, denormalizeSystemInformation, type Piece, pieceCache, User } from "@/cache";
import { client } from "@/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useLoading } from "@/hooks/useLoading";
import { Updater } from "@/hooks/useStore";
import { useLoadingEffect } from "@/hooks/useLoading";
import { mapProp, Update, Updater } from "@/hooks/useStore";
import { timeout } from "@/lib/utils";
import { Label } from "@radix-ui/react-label";
import type { Attachment, Piece } from "backend/database";
import clsx from "clsx";
import { PieceId } from "common";
import { getMediaTypeForFilename } from "common/MediaType";
import { ELYSIA_FORM_DATA } from "elysia";
import { Cause, Effect, Option } from "effect";
import { constant } from "effect/Function";
import { Download, Loader2, Trash, UploadCloud } from "lucide-react";
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -19,10 +20,14 @@ export function Piece() {
const id = PieceId(useParams().pieceId!);
const piece = useLoading(() => client.piece.get({ query: { id } }));
const attachments = useLoading(() => client.piece({ pieceId: id }).attachment.get());
const { isLoading, error, data, setData } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(id)));
if (piece.isLoading || attachments.isLoading) {
const setAttachments = useCallback((action: Update<readonly Attachment[]>) => {
setData!(mapProp("attachments", action));
Effect.runFork(pieceCache.invalidate(id));
}, [setData]);
if (isLoading) {
return (
<div className="w-full h-full overflow-hidden flex items-center justify-center">
<div>Ładowanie</div>
@@ -32,18 +37,16 @@ export function Piece() {
return (
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
{piece.error !== null || attachments.error !== null ? (
`Wystąpił błąd: ${[piece.error?.value, attachments.error?.value].filter(Boolean).join(", ")}`
) : piece.data[0] === undefined ? (
"Utwór nie istnieje"
{error !== null ? (
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}`
) : (<>
<div className="flex flex-col gap-4 p-4 border rounded">
<h3 className="font-bold text-lg">Utwór</h3>
<PieceForm piece={piece.data[0]} />
<PieceForm piece={data} />
<h3 className="font-bold text-lg">Załączniki</h3>
<AttachmentForm pieceId={piece.data[0].pieceId} setAttachments={attachments.setData} />
<AttachmentForm pieceId={id} setAttachments={setAttachments} />
</div>
<Attachments pieceId={piece.data[0].pieceId} attachments={attachments.data} setAttachments={attachments.setData} />
<Attachments pieceId={id} attachments={data.attachments} setAttachments={setAttachments} />
</>)}
</div>
);
@@ -58,9 +61,9 @@ namespace PieceForm {
function PieceForm(props: PieceForm.Props) {
const [name, setName] = useState(props.piece.name);
const [composer, setComposer] = useState(props.piece.composer ?? "");
const [lyricist, setLyricist] = useState(props.piece.lyricist ?? "");
const [arranger, setArranger] = useState(props.piece.arranger ?? "");
const [composer, setComposer] = useState(() => Option.getOrElse(props.piece.composer, constant("")));
const [lyricist, setLyricist] = useState(() => Option.getOrElse(props.piece.lyricist, constant("")));
const [arranger, setArranger] = useState(() => Option.getOrElse(props.piece.arranger, constant("")));
const nameId = useId();
const composerId = useId();
@@ -83,7 +86,7 @@ function PieceForm(props: PieceForm.Props) {
arranger: arranger.length > 0 ? arranger : null,
});
if (error) {
if (error !== null) {
console.error(error.value);
return;
}
@@ -144,7 +147,7 @@ namespace Attachments {
export interface Props {
readonly pieceId: PieceId;
readonly attachments: readonly Attachment[];
readonly setAttachments: Updater<Attachment[]>;
readonly setAttachments: Updater<readonly Attachment[]>;
}
}
@@ -178,28 +181,27 @@ function Attachments(props: Attachments.Props) {
namespace AttachmentRow {
export interface Props {
readonly attachment: Attachment;
readonly setAttachments: Updater<Attachment[]>;
readonly setAttachments: Updater<readonly Attachment[]>;
}
}
function AttachmentRow(props: AttachmentRow.Props) {
const download = useCallback(async () => {
const { error, data: _data } = await client
const { error, data } = await client
.piece({ pieceId: props.attachment.pieceId })
.attachment({ attachmentId: props.attachment.attachmentId })
.get();
.get()
if (error !== null) {
console.error(error.value);
return;
}
const data = _data as unknown as typeof _data[ELYSIA_FORM_DATA];
const url = URL.createObjectURL(data.data);
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = data.filename;
a.download = data.name;
a.click();
URL.revokeObjectURL(url);
}, [props.attachment.attachmentId, props.attachment.pieceId]);
@@ -211,7 +213,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
event.preventDefault();
const { error, data: _data } = await client
const { error, data } = await client
.piece({ pieceId: props.attachment.pieceId })
.attachment({ attachmentId: props.attachment.attachmentId })
.get();
@@ -221,8 +223,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
return;
}
const data = _data as unknown as typeof _data[ELYSIA_FORM_DATA];
const url = URL.createObjectURL(data.data);
const url = URL.createObjectURL(data);
window.open(url, "_target");
URL.revokeObjectURL(url);
}, [props.attachment.mediaType, props.attachment.attachmentId, props.attachment.pieceId]);
@@ -256,16 +257,18 @@ function AttachmentRow(props: AttachmentRow.Props) {
props.attachment.filename
)}
</TableCell>
<TableCell>{props.attachment.mediaType}</TableCell>
<TableCell className="text-center font-mono text-xs">
{props.attachment.createdAt}
{props.attachment.createdBy !== null && <><br />przez {props.attachment.createdBy}</>}
<TableCell>
{props.attachment.mediaType}
</TableCell>
<TableCell className="text-center font-mono text-xs">
{props.attachment.modifiedAt === null && props.attachment.modifiedBy === null ? "\u2014"
: props.attachment.modifiedAt !== null && props.attachment.modifiedBy === null ? props.attachment.modifiedAt
: props.attachment.modifiedAt === null ? `przez ${props.attachment.createdBy}`
: <>{props.attachment.createdAt}<br />przez {props.attachment.createdBy}</>}
<TableCell className="text-center text-xs">
{props.attachment.createdAt}
{Option.isSome(props.attachment.createdBy) && <><br />przez {props.attachment.createdBy.value.username}</>}
</TableCell>
<TableCell className="text-center text-xs">
{Option.isNone(props.attachment.modifiedAt) && Option.isNone(props.attachment.modifiedBy) ? "\u2014"
: Option.isSome(props.attachment.modifiedAt) && Option.isNone(props.attachment.modifiedBy) ? props.attachment.modifiedAt.value
: Option.isNone(props.attachment.modifiedAt) ? `przez ${(props.attachment.modifiedBy as Option.Some<User>).value.username}`
: <>{props.attachment.modifiedAt.value}<br />przez {(props.attachment.modifiedBy as Option.Some<User>).value.username}</>}
</TableCell>
<TableCell className="text-center flex justify-center gap-4">
<Button type="button" variant="ghost" size="icon" title="Pobierz" onClick={download}>
@@ -282,7 +285,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
namespace AttachmentForm {
export interface Props {
readonly pieceId: PieceId;
readonly setAttachments: Updater<Attachment[]>;
readonly setAttachments: Updater<readonly Attachment[]>;
}
}
@@ -323,8 +326,10 @@ function AttachmentForm(props: AttachmentForm.Props) {
continue;
}
const attachment = await Effect.runPromise(denormalizeSystemInformation(data));
props.setAttachments((prev) => {
const next = [...prev, data];
const next = [...prev, attachment];
next.sort((a, b) => a.filename.localeCompare(b.filename));
return next;
});