diff --git a/eslint.config.mjs b/eslint.config.mjs index d649704..4cf338f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,7 @@ import eslint from "@eslint/js"; -import tseslint from "typescript-eslint"; import stylistic from "@stylistic/eslint-plugin"; +import reactHooks from "eslint-plugin-react-hooks"; +import tseslint from "typescript-eslint"; export default tseslint.config({ extends: [ @@ -10,6 +11,7 @@ export default tseslint.config({ files: ["packages/*/src/**/*.{ts,tsx}"], plugins: { "@stylistic": stylistic, + "react-hooks": reactHooks, }, languageOptions: { parserOptions: { @@ -43,5 +45,7 @@ export default tseslint.config({ "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }], + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error", }, }); diff --git a/package.json b/package.json index b6004a7..9e2a076 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "devDependencies": { "@eslint/js": "catalog:", "@stylistic/eslint-plugin": "catalog:", + "eslint-plugin-react-hooks": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:" } diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index 0cea104..89caacb 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -33,6 +33,12 @@ export interface Piece extends SystemInformation { readonly attachments: readonly Attachment[]; } +export interface Repertoire extends SystemInformation { + readonly repertoireId: RepertoireId; + readonly name: string; + readonly entries: readonly Piece[]; +} + interface DbSystemInformation { readonly createdBy: UserId | null; readonly createdAt: string; @@ -108,6 +114,7 @@ const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1); export const userLookup = (userId: UserId) => pipe( Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })), Effect.flatMap(mapResponse), + Effect.map((x): User => x), // safely coerce to interface UserSemaphore.withPermits(1), ); @@ -115,6 +122,7 @@ export const pieceLookup = (pieceId: PieceId) => pipe( Effect.promise((signal) => client.piece({ pieceId }).get({ fetch: { signal } })), Effect.flatMap(mapResponse), Effect.flatMap(denormalizePiece), + Effect.map((x): Piece => x), // safely coerce to interface CacheSemaphore.withPermits(1), ); @@ -122,6 +130,7 @@ export const repertoireLookup = (repertoireId: RepertoireId) => pipe( Effect.promise((signal) => client.repertoire({ repertoireId }).get({ fetch: { signal } })), Effect.flatMap(mapResponse), Effect.flatMap(denormalizeRepertoire), + Effect.map((x): Repertoire => x), // safely coerce to interface RepertoireSemaphore.withPermits(1), ); diff --git a/packages/frontend/src/hooks/useLoading.ts b/packages/frontend/src/hooks/useLoading.ts index a4621a9..cca637e 100644 --- a/packages/frontend/src/hooks/useLoading.ts +++ b/packages/frontend/src/hooks/useLoading.ts @@ -81,7 +81,8 @@ export function useLoading>(fn: () => Promise< }); return () => { cancelled = true; }; - }, deps); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fn, navigate, setUser, ...deps]); return result; } @@ -125,7 +126,8 @@ export function useLoadingEffect(effect: Effect.Effect, deps: React. return () => { Effect.runFork(interruptEffect); }; - }, deps); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effect, setResultEffect, ...deps]); return result; } diff --git a/packages/frontend/src/hooks/useStore.ts b/packages/frontend/src/hooks/useStore.ts index 44d2f18..4ddf40d 100644 --- a/packages/frontend/src/hooks/useStore.ts +++ b/packages/frontend/src/hooks/useStore.ts @@ -88,7 +88,7 @@ export function useStore(selector: Selector = Function.identity as return () => { listeners.delete(listener); }; - }, []); + }, [selector]); return state; } diff --git a/packages/frontend/src/routes/Attachment.tsx b/packages/frontend/src/routes/Attachment.tsx index 1ab7e47..92e9808 100644 --- a/packages/frontend/src/routes/Attachment.tsx +++ b/packages/frontend/src/routes/Attachment.tsx @@ -40,7 +40,7 @@ export default function Attachment() { URL.revokeObjectURL(url); window.removeEventListener("resize", render); }; - }, [isLoading, data]); + }, [data, error, isLoading]); if (isLoading) { return ( diff --git a/packages/frontend/src/routes/Piece.tsx b/packages/frontend/src/routes/Piece.tsx index cd68b7f..8765ec6 100644 --- a/packages/frontend/src/routes/Piece.tsx +++ b/packages/frontend/src/routes/Piece.tsx @@ -25,7 +25,7 @@ export function Piece() { const setAttachments = useCallback((action: Update) => { setData!(mapProp("attachments", action)); Effect.runFork(pieceCache.invalidate(id)); - }, [setData]); + }, [id, setData]); if (isLoading) { return ( @@ -236,7 +236,7 @@ function AttachmentRow(props: AttachmentRow.Props) { const url = URL.createObjectURL(data); window.open(url, "_target"); URL.revokeObjectURL(url); - }, [props.attachment.mediaType, props.attachment.attachmentId, props.attachment.pieceId]); + }, [props.attachment.attachmentId, props.attachment.mediaType, props.attachment.pieceId]); const doDelete = useCallback(async () => { @@ -252,7 +252,7 @@ function AttachmentRow(props: AttachmentRow.Props) { props.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId)); - }, [props.attachment.attachmentId, props.attachment.pieceId]); + }, [props]); return ( diff --git a/packages/frontend/src/routes/Repertoire.tsx b/packages/frontend/src/routes/Repertoire.tsx index 7127d4e..b5da472 100644 --- a/packages/frontend/src/routes/Repertoire.tsx +++ b/packages/frontend/src/routes/Repertoire.tsx @@ -1,10 +1,17 @@ -import { Piece, repertoireCache } from "@/cache"; +import { Piece, type Repertoire, repertoireCache } from "@/cache"; +import { client } from "@/client"; +import { Button } from "@/components/ui/button"; +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 } from "@/hooks/useStore"; -import { RepertoireId } from "common"; -import { Cause, Effect } from "effect"; -import { useCallback } from "react"; -import { useParams } from "react-router-dom"; +import { mapProp, Update, Updater } from "@/hooks/useStore"; +import { timeout } from "@/lib/utils"; +import { PieceId, RepertoireId } from "common"; +import { Array, Cause, Effect, Option, pipe } from "effect"; +import { CircleMinus, Loader2 } from "lucide-react"; +import { FormEventHandler, ReactNode, useCallback, useId, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; export function Repertoire() { @@ -15,7 +22,7 @@ export function Repertoire() { const setEntries = useCallback((action: Update) => { setData!(mapProp("entries", action)); Effect.runFork(repertoireCache.invalidate(id)); - }, [setData]); + }, [id, setData]); if (isLoading) { return ( @@ -34,8 +41,188 @@ export function Repertoire() {

Repertuar

- + )} ); } + +namespace RepertoireForm { + export interface Props { + readonly repertoire: Repertoire; + } +} + +function RepertoireForm(props: RepertoireForm.Props) { + + const navigate = useNavigate(); + + const [name, setName] = useState(props.repertoire.name); + + const nameId = useId(); + + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const onSubmit: FormEventHandler = async (e) => { + e.preventDefault(); + + const delay = timeout(250); + try { + setIsSaving(true); + + const { error } = await client.repertoire({ repertoireId: props.repertoire.repertoireId }).put({ + name, + entries: props.repertoire.entries.map(({ pieceId }) => pieceId), + }); + + if (error !== null) { + console.error(error.value); + return; + } + } finally { + await delay; + setIsSaving(false); + } + }; + + const doDelete = useCallback(async () => { + try { + setIsDeleting(true); + + const { error } = await client + .repertoire({ repertoireId: props.repertoire.repertoireId }) + .delete(); + + if (error !== null) { + console.error(error.value); + return; + } + + Effect.runFork(repertoireCache.invalidate(props.repertoire.repertoireId)); + navigate(".."); + } finally { + setIsDeleting(false); + } + }, [props.repertoire.repertoireId, navigate]); + + return ( +
+
+ + setName(e.target.value)} + /> +
+
+ + +
+
+ ); +} + +namespace Entries { + export interface Props { + readonly repertoire: Repertoire; + readonly setEntries: Updater; + } +} + +function Entries(props: Entries.Props) { + return ( +
+ + + + Lp. + Tytuł + Twórcy + Akcje + + + + {props.repertoire.entries.map((entry, i) => ( + + ))} + +
+
+ ); +} + +namespace EntryRow { + export interface Props { + readonly repertoire: Repertoire; + readonly piece: Piece; + readonly no: number; + readonly setEntries: Updater; + } +} + +function EntryRow(props: EntryRow.Props) { + + const remove = useCallback(async () => { + + 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 }) + .put({ + name: props.repertoire.name, + entries: pipe(props.repertoire.entries, filter, mapToId), + }); + + if (error !== null) { + console.error(error.value); + return; + } + + props.setEntries(filter); + + }, [props]); + + const composerParts: ReactNode[] = []; + + if (Option.isSome(props.piece.composer)) composerParts.push(props.piece.composer.value); + if (Option.isSome(props.piece.arranger)) composerParts.push(`opracowanie: ${props.piece.arranger.value}`); + if (Option.isSome(props.piece.lyricist)) composerParts.push(`słowa: ${props.piece.lyricist.value}`); + if (composerParts.length === 0) composerParts.push(Nieznani); + + return ( + + + {props.no} + + + {props.piece.name} + + + {...composerParts.flatMap((x, i, a) => i < a.length - 1 ? [x,
] : [x])} +
+ + + +
+ ); +} diff --git a/packages/frontend/src/routes/Root.tsx b/packages/frontend/src/routes/Root.tsx index 4a34d30..5fe74f5 100644 --- a/packages/frontend/src/routes/Root.tsx +++ b/packages/frontend/src/routes/Root.tsx @@ -26,6 +26,7 @@ export function Root() { setUser(data); }; + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => void init(), []); const onLogoutClick = async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1743619..76fe5cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ catalogs: elysia: specifier: ^1.1.25 version: 1.1.25 + eslint-plugin-react-hooks: + specifier: ^5.1.0 + version: 5.1.0 kysely: specifier: ^0.27.4 version: 0.27.4 @@ -116,6 +119,9 @@ importers: '@stylistic/eslint-plugin': specifier: 'catalog:' version: 2.12.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2) + eslint-plugin-react-hooks: + specifier: 'catalog:' + version: 5.1.0(eslint@9.17.0(jiti@1.21.6)) typescript: specifier: 'catalog:' version: 5.7.2 @@ -1422,6 +1428,12 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-plugin-react-hooks@5.1.0: + resolution: {integrity: sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-scope@8.2.0: resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3695,6 +3707,10 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-plugin-react-hooks@5.1.0(eslint@9.17.0(jiti@1.21.6)): + dependencies: + eslint: 9.17.0(jiti@1.21.6) + eslint-scope@8.2.0: dependencies: esrecurse: 4.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 20f0ba9..0808841 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,7 @@ catalog: clsx: '^2.1.1' effect: '^3.11.4' elysia: '^1.1.25' + eslint-plugin-react-hooks: '^5.1.0' kysely: '^0.27.4' kysely-bun-sqlite: '^0.3.2' lucide-react: '^0.462.0'