Cache reimplementation
This commit is contained in:
149
packages/common/src/Cache.ts
Normal file
149
packages/common/src/Cache.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Deferred, Effect, Exit, flow, HashMap, Option } from "effect";
|
||||||
|
|
||||||
|
export type FetchFn<K, A, E> = (key: K) => Effect.Effect<A, E>;
|
||||||
|
export type ListenFn<K, A, E> = (state: Map<K, A, E>) => void;
|
||||||
|
|
||||||
|
export interface Pending<A, E> {
|
||||||
|
readonly _tag: "Pending";
|
||||||
|
readonly deferred: Deferred.Deferred<A, E>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Fulfilled<A, E> {
|
||||||
|
readonly _tag: "Fulfilled";
|
||||||
|
readonly exit: Exit.Exit<A, E>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type State<A, E> =
|
||||||
|
| Pending<A, E>
|
||||||
|
| Fulfilled<A, E>;
|
||||||
|
|
||||||
|
export const Pending = <A, E>(deferred: Deferred.Deferred<A, E>): Pending<A, E> => Object.freeze<Pending<A, E>>({
|
||||||
|
_tag: "Pending",
|
||||||
|
deferred,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Fulfilled = <A, E>(exit: Exit.Exit<A, E>): Fulfilled<A, E> => Object.freeze<Fulfilled<A, E>>({
|
||||||
|
_tag: "Fulfilled",
|
||||||
|
exit,
|
||||||
|
});
|
||||||
|
|
||||||
|
declare const CacheTypeId: unique symbol;
|
||||||
|
type CacheTypeId = typeof CacheTypeId;
|
||||||
|
|
||||||
|
export interface Cache<K, A, E> extends CacheInterface<K, A, E> {
|
||||||
|
readonly [CacheTypeId]: {
|
||||||
|
readonly _K: (_: K) => K,
|
||||||
|
readonly _A: (_: A) => A,
|
||||||
|
readonly _E: (_: E) => E,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheInterface<K, A, E> {
|
||||||
|
readonly setPending: (key: K, deferred: Deferred.Deferred<A, E>) => void;
|
||||||
|
readonly setFulfilled: (key: K, exit: Exit.Exit<A, E>) => void;
|
||||||
|
readonly setFulfilledSucceed: (key: K, value: A) => void;
|
||||||
|
readonly unset: (key: K) => void;
|
||||||
|
readonly update: (key: K, action: (prev: A) => A) => void;
|
||||||
|
readonly refresh: (key: K) => Effect.Effect<A, E>;
|
||||||
|
readonly get: (key: K) => Effect.Effect<A, E>;
|
||||||
|
readonly getCurrent: (key: K) => Option.Option<State<A, E>>;
|
||||||
|
readonly subscribe: (callback: ListenFn<K, A, E>) => (() => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Key<T extends Cache<any, any, any>> = T extends Cache<infer K, any, any> ? K : never;
|
||||||
|
export type Value<T extends Cache<any, any, any>> = T extends Cache<any, infer A, any> ? A : never;
|
||||||
|
export type Error<T extends Cache<any, any, any>> = T extends Cache<any, any, infer E> ? E : never;
|
||||||
|
|
||||||
|
export type Map<K, A, E> = HashMap.HashMap<K, State<A, E>>;
|
||||||
|
|
||||||
|
export const make = <K, A, E>(fetchFn: FetchFn<K, A, E>): Cache<K, A, E> => {
|
||||||
|
|
||||||
|
let map: Map<K, A, E> = HashMap.empty();
|
||||||
|
|
||||||
|
const listeners = new Set<ListenFn<K, A, E>>();
|
||||||
|
|
||||||
|
const setPending = (key: K, deferred: Deferred.Deferred<A, E>) => {
|
||||||
|
const pending = Pending(deferred);
|
||||||
|
map = HashMap.set(map, key, pending);
|
||||||
|
listeners.forEach((callback) => callback(map));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFulfilled = (key: K, exit: Exit.Exit<A, E>) => {
|
||||||
|
const fulfilled = Fulfilled(exit);
|
||||||
|
map = HashMap.set(map, key, fulfilled);
|
||||||
|
listeners.forEach((callback) => callback(map));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFulfilledSucceed = (key: K, value: A) => {
|
||||||
|
setFulfilled(key, Exit.succeed(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const unset = (key: K) => {
|
||||||
|
map = HashMap.remove(map, key);
|
||||||
|
listeners.forEach((callback) => callback(map));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (key: K, update: (prev: A) => A) => {
|
||||||
|
map = HashMap.modify(map, key, (state) => {
|
||||||
|
switch (state._tag) {
|
||||||
|
case "Pending":
|
||||||
|
return state;
|
||||||
|
case "Fulfilled":
|
||||||
|
return Exit.match(state.exit, {
|
||||||
|
onFailure: () => state,
|
||||||
|
onSuccess: flow(update, Exit.succeed, Fulfilled),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listeners.forEach((callback) => callback(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = (key: K): Effect.Effect<A, E> => Effect.gen(function* () {
|
||||||
|
const deferred = yield* Deferred.make<A, E>();
|
||||||
|
setPending(key, deferred);
|
||||||
|
|
||||||
|
const exit = yield* Effect.exit(fetchFn(key));
|
||||||
|
setFulfilled(key, exit);
|
||||||
|
|
||||||
|
yield* Deferred.done(deferred, exit);
|
||||||
|
return yield* exit;
|
||||||
|
}).pipe(Effect.uninterruptible);
|
||||||
|
|
||||||
|
const get = (key: K): Effect.Effect<A, E> => Effect.gen(function* () {
|
||||||
|
const state = Option.getOrNull(HashMap.get(map, key));
|
||||||
|
|
||||||
|
if (state === null) {
|
||||||
|
return yield* refresh(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (state._tag) {
|
||||||
|
case "Pending":
|
||||||
|
return yield* Deferred.await(state.deferred);
|
||||||
|
case "Fulfilled":
|
||||||
|
return yield* state.exit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCurrent = (key: K): Option.Option<State<A, E>> => HashMap.get(map, key);
|
||||||
|
|
||||||
|
const subscribe = (callback: ListenFn<K, A, E>) => {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(callback);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheInterface = Object.freeze<CacheInterface<K, A, E>>({
|
||||||
|
setPending,
|
||||||
|
setFulfilled,
|
||||||
|
setFulfilledSucceed,
|
||||||
|
unset,
|
||||||
|
update,
|
||||||
|
refresh,
|
||||||
|
get,
|
||||||
|
getCurrent,
|
||||||
|
subscribe,
|
||||||
|
});
|
||||||
|
|
||||||
|
return cacheInterface as Cache<K, A, E>;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PieceId, RepertoireId, UserId } from "common";
|
import { client } from "@/client";
|
||||||
|
import * as Cache from "common/Cache";
|
||||||
import the_api, { SystemInformation } from "common/the_api";
|
import the_api, { SystemInformation } from "common/the_api";
|
||||||
import { Array, Cache, Duration, Effect, pipe } from "effect";
|
import { Array, Effect, flow, pipe } from "effect";
|
||||||
import { client } from "./client";
|
|
||||||
|
|
||||||
export const denormalizeSystemInformation = <T extends SystemInformation>({
|
export const denormalizeSystemInformation = <T extends SystemInformation>({
|
||||||
createdBy,
|
createdBy,
|
||||||
@@ -11,12 +11,12 @@ export const denormalizeSystemInformation = <T extends SystemInformation>({
|
|||||||
Effect.all({
|
Effect.all({
|
||||||
createdBy: pipe(
|
createdBy: pipe(
|
||||||
createdBy,
|
createdBy,
|
||||||
Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))),
|
Effect.flatMap(userCache.get),
|
||||||
Effect.optionFromOptional,
|
Effect.optionFromOptional,
|
||||||
),
|
),
|
||||||
modifiedBy: pipe(
|
modifiedBy: pipe(
|
||||||
modifiedBy,
|
modifiedBy,
|
||||||
Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))),
|
Effect.flatMap(userCache.get),
|
||||||
Effect.optionFromOptional,
|
Effect.optionFromOptional,
|
||||||
),
|
),
|
||||||
}, { concurrency: "unbounded" }),
|
}, { concurrency: "unbounded" }),
|
||||||
@@ -45,7 +45,7 @@ export const denormalizeRepertoire = ({
|
|||||||
...rest
|
...rest
|
||||||
}: typeof the_api.record.getRepertoire.response.Type) => pipe(
|
}: typeof the_api.record.getRepertoire.response.Type) => pipe(
|
||||||
Effect.all({
|
Effect.all({
|
||||||
entries: Effect.all(entries.map((entry) => Effect.uninterruptible(pieceCache.get(entry))), { concurrency: "unbounded" }),
|
entries: Effect.all(entries.map(pieceCache.get), { concurrency: "unbounded" }),
|
||||||
}, { concurrency: "unbounded" }),
|
}, { concurrency: "unbounded" }),
|
||||||
Effect.map((repertoire) => Object.freeze({
|
Effect.map((repertoire) => Object.freeze({
|
||||||
...rest,
|
...rest,
|
||||||
@@ -54,42 +54,13 @@ export const denormalizeRepertoire = ({
|
|||||||
Effect.flatMap(denormalizeSystemInformation),
|
Effect.flatMap(denormalizeSystemInformation),
|
||||||
);
|
);
|
||||||
|
|
||||||
const UserSemaphore = Effect.unsafeMakeSemaphore(1);
|
export const userCache = Cache.make(client.getUser);
|
||||||
const PieceSemaphore = Effect.unsafeMakeSemaphore(4);
|
export const pieceCache = Cache.make(flow(client.getPiece, Effect.flatMap(denormalizePiece)));
|
||||||
const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
|
export const repertoireCache = Cache.make(flow(client.getRepertoire, Effect.flatMap(denormalizeRepertoire)));
|
||||||
|
|
||||||
export const userCache = Effect.runSync(Cache.make({
|
export type User = Cache.Value<typeof userCache>;
|
||||||
capacity: Infinity,
|
export type Piece = Cache.Value<typeof pieceCache>;
|
||||||
timeToLive: Duration.days(1),
|
export type Repertoire = Cache.Value<typeof repertoireCache>;
|
||||||
lookup: (userId: UserId) => pipe(
|
|
||||||
client.getUser(userId),
|
|
||||||
UserSemaphore.withPermits(1),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const pieceCache = Effect.runSync(Cache.make({
|
|
||||||
capacity: Infinity,
|
|
||||||
timeToLive: Duration.days(1),
|
|
||||||
lookup: (pieceId: PieceId) => pipe(
|
|
||||||
client.getPiece(pieceId),
|
|
||||||
Effect.flatMap(denormalizePiece),
|
|
||||||
PieceSemaphore.withPermits(1),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const repertoireCache = Effect.runSync(Cache.make({
|
|
||||||
capacity: Infinity,
|
|
||||||
timeToLive: Duration.days(1),
|
|
||||||
lookup: (repertoireId: RepertoireId) => pipe(
|
|
||||||
client.getRepertoire(repertoireId),
|
|
||||||
Effect.flatMap(denormalizeRepertoire),
|
|
||||||
RepertoireSemaphore.withPermits(1),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export type User = Effect.Effect.Success<ReturnType<typeof userCache["get"]>>;
|
|
||||||
export type Piece = Effect.Effect.Success<ReturnType<typeof pieceCache["get"]>>;
|
|
||||||
export type Repertoire = Effect.Effect.Success<ReturnType<typeof repertoireCache["get"]>>;
|
|
||||||
|
|
||||||
export type Attachment = Array.ReadonlyArray.Infer<Piece["attachments"]>;
|
export type Attachment = Array.ReadonlyArray.Infer<Piece["attachments"]>;
|
||||||
export type DenormalizedSystemInformation = Effect.Effect.Success<ReturnType<typeof denormalizeSystemInformation<SystemInformation>>>;
|
export type DenormalizedSystemInformation = Effect.Effect.Success<ReturnType<typeof denormalizeSystemInformation<SystemInformation>>>;
|
||||||
|
|||||||
72
packages/frontend/src/hooks/useCache.ts
Normal file
72
packages/frontend/src/hooks/useCache.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import * as Cache from "common/Cache";
|
||||||
|
import { Cause, Effect, Either, Exit, flow, Option } from "effect";
|
||||||
|
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export namespace Loading {
|
||||||
|
export interface Pending {
|
||||||
|
readonly isLoading: true;
|
||||||
|
readonly data: null;
|
||||||
|
readonly error: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Success<A> {
|
||||||
|
readonly isLoading: false;
|
||||||
|
readonly data: A;
|
||||||
|
readonly error: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Error<E> {
|
||||||
|
readonly isLoading: false;
|
||||||
|
readonly data: null;
|
||||||
|
readonly error: E;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Loading<A, E> =
|
||||||
|
| Loading.Pending
|
||||||
|
| Loading.Success<A>
|
||||||
|
| Loading.Error<E>
|
||||||
|
;
|
||||||
|
|
||||||
|
const PENDING: Loading.Pending = Object.freeze<Loading.Pending>({
|
||||||
|
isLoading: true,
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveState<A, E>(maybeState: Option.Option<Cache.State<A, E>>): Loading<A, E> {
|
||||||
|
const state = Option.getOrNull(maybeState);
|
||||||
|
|
||||||
|
if (state === null) {
|
||||||
|
return PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (state._tag) {
|
||||||
|
case "Pending":
|
||||||
|
return PENDING;
|
||||||
|
case "Fulfilled":
|
||||||
|
return Exit.match(state.exit, {
|
||||||
|
onFailure: flow(
|
||||||
|
Cause.failureOrCause,
|
||||||
|
Either.match({
|
||||||
|
onLeft: (error) => Object.freeze<Loading.Error<E>>({ isLoading: false, data: null, error }),
|
||||||
|
onRight: (cause) => Object.freeze<Loading.Error<E>>({ isLoading: false, data: null, error: cause as any }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (data) => Object.freeze<Loading.Success<A>>({ isLoading: false, data, error: null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCache<K, A, E>(cache: Cache.Cache<K, A, E>, key: NoInfer<K>, refresh: boolean = false) {
|
||||||
|
|
||||||
|
const selector = useCallback(() => cache.getCurrent(key), [cache.getCurrent, key]);
|
||||||
|
|
||||||
|
const state = useSyncExternalStore(cache.subscribe, selector);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Effect.runFork(refresh ? cache.refresh(key) : cache.get(key));
|
||||||
|
}, [cache.get, key, refresh]);
|
||||||
|
|
||||||
|
return resolveState(state);
|
||||||
|
}
|
||||||
@@ -1,27 +1,26 @@
|
|||||||
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
|
||||||
import { Console, Effect, Fiber, pipe } from "effect";
|
import { Console, Effect, Fiber, pipe } from "effect";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export namespace Loading {
|
export namespace Loading {
|
||||||
export interface Pending {
|
export interface Pending {
|
||||||
readonly isLoading: true;
|
readonly isLoading: true;
|
||||||
readonly data: null;
|
readonly data: null;
|
||||||
readonly error: null;
|
readonly error: null;
|
||||||
readonly setData: null;
|
readonly refresh: Effect.Effect<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Success<A> {
|
export interface Success<A> {
|
||||||
readonly isLoading: false;
|
readonly isLoading: false;
|
||||||
readonly data: A;
|
readonly data: A;
|
||||||
readonly error: null;
|
readonly error: null;
|
||||||
readonly setData: Updater<A>;
|
readonly refresh: Effect.Effect<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Error<E> {
|
export interface Error<E> {
|
||||||
readonly isLoading: false;
|
readonly isLoading: false;
|
||||||
readonly data: null;
|
readonly data: null;
|
||||||
readonly error: E;
|
readonly error: E;
|
||||||
readonly setData: null;
|
readonly refresh: Effect.Effect<void>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ export type Loading<A, E> =
|
|||||||
| Loading.Error<E>
|
| Loading.Error<E>
|
||||||
;
|
;
|
||||||
|
|
||||||
const IS_LOADING = Object.freeze({
|
const PENDING = Object.freeze({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
data: null,
|
data: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -40,31 +39,41 @@ const IS_LOADING = Object.freeze({
|
|||||||
|
|
||||||
export function useLoading<A, E>(effect: Effect.Effect<A, E>, deps: React.DependencyList) {
|
export function useLoading<A, E>(effect: Effect.Effect<A, E>, deps: React.DependencyList) {
|
||||||
|
|
||||||
const [result, setResult] = useState<Loading<A, E>>(IS_LOADING);
|
|
||||||
const setResultEffect = useCallback((action: Loading<A, E>) => Effect.sync(() => setResult(action)), []);
|
const setResultEffect = useCallback((action: Loading<A, E>) => Effect.sync(() => setResult(action)), []);
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh: Effect.Effect<void> = useMemo(() => pipe(
|
||||||
setResult(IS_LOADING);
|
|
||||||
|
|
||||||
const fiber = pipe(
|
|
||||||
effect,
|
effect,
|
||||||
Effect.tapBoth({
|
Effect.tap((data) => setResultEffect(Object.freeze<Loading.Success<A>>({
|
||||||
onSuccess: (data) => setResultEffect(Object.freeze<Loading.Success<A>>({
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
data,
|
data,
|
||||||
error: null,
|
error: null,
|
||||||
setData: (action) => setResult(mapProp("data", action) as Update<Loading<A, E>>),
|
refresh,
|
||||||
})),
|
}))),
|
||||||
onFailure: (error) => setResultEffect(Object.freeze<Loading.Error<E>>({
|
Effect.catchAll((error) => setResultEffect(Object.freeze<Loading.Error<E>>({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
data: null,
|
data: null,
|
||||||
error,
|
error,
|
||||||
setData: null,
|
refresh,
|
||||||
})),
|
}))),
|
||||||
}),
|
|
||||||
Effect.catchAllDefect(Console.error),
|
Effect.catchAllDefect(Console.error),
|
||||||
Effect.runFork,
|
), [effect]);
|
||||||
);
|
|
||||||
|
const [result, setResult] = useState<Loading<A, E>>(() => Object.freeze<Loading.Pending>({
|
||||||
|
isLoading: true,
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
refresh,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResult(Object.freeze<Loading.Pending>({
|
||||||
|
isLoading: true,
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
refresh,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fiber = Effect.runFork(refresh);
|
||||||
const interruptEffect = Fiber.interrupt(fiber);
|
const interruptEffect = Fiber.interrupt(fiber);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Attachment, denormalizeSystemInformation, pieceCache, type Piece } from "@/cache";
|
import { Attachment, denormalizeSystemInformation, pieceCache, type Piece } from "@/cache";
|
||||||
import { API_URL_PREFIX, client } from "@/client";
|
import { API_URL_PREFIX, client } from "@/client";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useCache } from "@/hooks/useCache";
|
||||||
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
import { mapProp } from "@/hooks/useStore";
|
||||||
import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets";
|
import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets";
|
||||||
import { Label } from "@radix-ui/react-label";
|
import { Label } from "@radix-ui/react-label";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { PieceId } from "common";
|
import { PieceId } from "common";
|
||||||
import * as Body from "common/Body";
|
import * as Body from "common/Body";
|
||||||
import { getMediaTypeForFilename } from "common/MediaType";
|
import { getMediaTypeForFilename } from "common/MediaType";
|
||||||
import { Cause, Clock, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect";
|
import { Array, Cause, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect";
|
||||||
import { constant } from "effect/Function";
|
import { constant } from "effect/Function";
|
||||||
import { Download, Loader2, Trash, UploadCloud } from "lucide-react";
|
import { Download, Loader2, Trash, UploadCloud } from "lucide-react";
|
||||||
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
|
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
|
||||||
@@ -21,12 +21,7 @@ export function Piece() {
|
|||||||
|
|
||||||
const id = PieceId.make(useParams().pieceId!);
|
const id = PieceId.make(useParams().pieceId!);
|
||||||
|
|
||||||
const { isLoading, error, data, setData } = useLoading(Effect.uninterruptible(pieceCache.get(id)), [id]);
|
const { isLoading, error, data } = useCache(pieceCache, id);
|
||||||
|
|
||||||
const setAttachments = useCallback((action: Update<readonly Attachment[]>) => {
|
|
||||||
setData!(mapProp("attachments", action));
|
|
||||||
Effect.runFork(pieceCache.invalidate(id));
|
|
||||||
}, [id, setData]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -45,9 +40,9 @@ export function Piece() {
|
|||||||
<h3 className="font-bold text-lg">Utwór</h3>
|
<h3 className="font-bold text-lg">Utwór</h3>
|
||||||
<PieceForm piece={data} />
|
<PieceForm piece={data} />
|
||||||
<h3 className="font-bold text-lg">Załączniki</h3>
|
<h3 className="font-bold text-lg">Załączniki</h3>
|
||||||
<AttachmentForm pieceId={id} setAttachments={setAttachments} />
|
<AttachmentForm pieceId={id} />
|
||||||
</div>
|
</div>
|
||||||
<Attachments pieceId={id} attachments={data.attachments} setAttachments={setAttachments} />
|
<Attachments pieceId={id} attachments={data.attachments} />
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -102,10 +97,8 @@ function PieceForm(props: PieceForm.Props) {
|
|||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
||||||
await Effect.runPromise(pipe(
|
await Effect.runPromise(client.deletePiece(props.piece.pieceId));
|
||||||
client.deletePiece(props.piece.pieceId),
|
pieceCache.unset(props.piece.pieceId);
|
||||||
Effect.andThen(pieceCache.invalidate(props.piece.pieceId)),
|
|
||||||
));
|
|
||||||
|
|
||||||
navigate("..");
|
navigate("..");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -170,7 +163,6 @@ namespace Attachments {
|
|||||||
export interface Props {
|
export interface Props {
|
||||||
readonly pieceId: PieceId;
|
readonly pieceId: PieceId;
|
||||||
readonly attachments: readonly Attachment[];
|
readonly attachments: readonly Attachment[];
|
||||||
readonly setAttachments: Updater<readonly Attachment[]>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +184,6 @@ function Attachments(props: Attachments.Props) {
|
|||||||
<AttachmentRow
|
<AttachmentRow
|
||||||
key={attachment.attachmentId}
|
key={attachment.attachmentId}
|
||||||
attachment={attachment}
|
attachment={attachment}
|
||||||
setAttachments={props.setAttachments}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -204,7 +195,6 @@ function Attachments(props: Attachments.Props) {
|
|||||||
namespace AttachmentRow {
|
namespace AttachmentRow {
|
||||||
export interface Props {
|
export interface Props {
|
||||||
readonly attachment: Attachment;
|
readonly attachment: Attachment;
|
||||||
readonly setAttachments: Updater<readonly Attachment[]>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +239,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
|||||||
|
|
||||||
const doDelete = () => Effect.gen(function* () {
|
const doDelete = () => Effect.gen(function* () {
|
||||||
yield* client.deleteAttachment(props.attachment.attachmentId);
|
yield* client.deleteAttachment(props.attachment.attachmentId);
|
||||||
props.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId));
|
pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter<Attachment>((a) => a.attachmentId !== props.attachment.attachmentId)));
|
||||||
}).pipe(Effect.runPromise);
|
}).pipe(Effect.runPromise);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -289,7 +279,6 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
|||||||
namespace AttachmentForm {
|
namespace AttachmentForm {
|
||||||
export interface Props {
|
export interface Props {
|
||||||
readonly pieceId: PieceId;
|
readonly pieceId: PieceId;
|
||||||
readonly setAttachments: Updater<readonly Attachment[]>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,11 +332,11 @@ function AttachmentForm(props: AttachmentForm.Props) {
|
|||||||
|
|
||||||
const attachment = yield* denormalizeSystemInformation(exit.value);
|
const attachment = yield* denormalizeSystemInformation(exit.value);
|
||||||
|
|
||||||
props.setAttachments((prev) => {
|
pieceCache.update(props.pieceId, mapProp("attachments", (attachments: readonly Attachment[]) => {
|
||||||
const next = [...prev, attachment];
|
const next = [...attachments, attachment];
|
||||||
next.sort((a, b) => a.filename.localeCompare(b.filename));
|
next.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||||
return next;
|
return next;
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}).pipe(Effect.runPromise);
|
}).pipe(Effect.runPromise);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { pieceCache } from "@/cache";
|
import { denormalizePiece, pieceCache } from "@/cache";
|
||||||
import { client } from "@/client";
|
import { client } from "@/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useCache } from "@/hooks/useCache";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { Updater } from "@/hooks/useStore";
|
import { Updater } from "@/hooks/useStore";
|
||||||
import { authors, created, DEBOUNCE, modified, SAVE_DELAY } from "@/snippets";
|
import { authors, created, DEBOUNCE, modified, SAVE_DELAY } from "@/snippets";
|
||||||
@@ -12,8 +13,8 @@ import clsx from "clsx";
|
|||||||
import { PieceId } from "common";
|
import { PieceId } from "common";
|
||||||
import * as Body from "common/Body";
|
import * as Body from "common/Body";
|
||||||
import { getMediaTypeForFilename } from "common/MediaType";
|
import { getMediaTypeForFilename } from "common/MediaType";
|
||||||
import { Array, Cause, Effect, Fiber, Iterable, Match, Option, Order, pipe, Scope, SortedMap } from "effect";
|
import { Array, Cause, Effect, Fiber, Iterable, Match, Option, Order, pipe, Predicate, Scope, SortedMap } from "effect";
|
||||||
import { Import, Loader2, Plus, UploadCloud } from "lucide-react";
|
import { Import, Loader2, Plus } from "lucide-react";
|
||||||
import { DragEventHandler, FormEventHandler, useId, useRef, useState } from "react";
|
import { DragEventHandler, FormEventHandler, useId, useRef, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ namespace PieceRow {
|
|||||||
|
|
||||||
function PieceRow(props: PieceRow.Props) {
|
function PieceRow(props: PieceRow.Props) {
|
||||||
|
|
||||||
const { isLoading, error, data: piece } = useLoading(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
|
const { isLoading, error, data: piece } = useCache(pieceCache, props.pieceId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -188,12 +189,16 @@ function AddPieceDialogContent() {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const { pieceId } = yield* client.createPiece({
|
const { pieceId } = yield* pipe(
|
||||||
|
client.createPiece({
|
||||||
name,
|
name,
|
||||||
composer: composer.length > 0 ? Option.some(composer) : Option.none(),
|
composer: composer.length > 0 ? Option.some(composer) : Option.none(),
|
||||||
lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(),
|
lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(),
|
||||||
arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(),
|
arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(),
|
||||||
});
|
}),
|
||||||
|
Effect.flatMap(denormalizePiece),
|
||||||
|
Effect.tap((piece) => Effect.sync(() => pieceCache.setFulfilledSucceed(piece.pieceId, piece))),
|
||||||
|
);
|
||||||
|
|
||||||
navigate(pieceId);
|
navigate(pieceId);
|
||||||
}));
|
}));
|
||||||
@@ -262,7 +267,7 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) {
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [attachments, setAttachments] = useState<SortedMap.SortedMap<string, readonly File[]>>(() => SortedMap.empty(Order.string));
|
const [files, setFiles] = useState<SortedMap.SortedMap<string, readonly File[]>>(() => SortedMap.empty(Order.string));
|
||||||
|
|
||||||
const onDragOver: DragEventHandler<HTMLElement> = (e) => {
|
const onDragOver: DragEventHandler<HTMLElement> = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -272,7 +277,7 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) {
|
|||||||
const onDrop: DragEventHandler<HTMLElement> = (e) => Effect.gen(function* () {
|
const onDrop: DragEventHandler<HTMLElement> = (e) => Effect.gen(function* () {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
setAttachments((value) => {
|
setFiles((value) => {
|
||||||
for (const file of e.dataTransfer.files) {
|
for (const file of e.dataTransfer.files) {
|
||||||
const mediaType = getMediaTypeForFilename(file.name);
|
const mediaType = getMediaTypeForFilename(file.name);
|
||||||
if (mediaType === undefined) {
|
if (mediaType === undefined) {
|
||||||
@@ -314,18 +319,20 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
yield* pipe(
|
yield* pipe(
|
||||||
attachments,
|
files,
|
||||||
Iterable.map(([name, attachments]) => Effect.gen(function* () {
|
Iterable.map(([name, files]) => Effect.gen(function* () {
|
||||||
|
|
||||||
const { pieceId } = yield* client.createPiece({
|
const piece = yield* pipe(
|
||||||
|
client.createPiece({
|
||||||
name,
|
name,
|
||||||
composer: Option.none(),
|
composer: Option.none(),
|
||||||
arranger: Option.none(),
|
arranger: Option.none(),
|
||||||
lyricist: Option.none(),
|
lyricist: Option.none(),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
yield* pipe(
|
const attachments = yield* pipe(
|
||||||
attachments,
|
files,
|
||||||
Array.map((file) => Effect.gen(function* () {
|
Array.map((file) => Effect.gen(function* () {
|
||||||
const mediaType = getMediaTypeForFilename(file.name);
|
const mediaType = getMediaTypeForFilename(file.name);
|
||||||
if (mediaType === undefined) {
|
if (mediaType === undefined) {
|
||||||
@@ -335,14 +342,23 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) {
|
|||||||
// NOTE Apparently, file.bytes is not a thing in this context
|
// NOTE Apparently, file.bytes is not a thing in this context
|
||||||
const data = new Uint8Array(yield* Body.arrayBuffer(file));
|
const data = new Uint8Array(yield* Body.arrayBuffer(file));
|
||||||
|
|
||||||
yield* client.createAttachment({
|
const attachment = yield* client.createAttachment({
|
||||||
pieceId,
|
pieceId: piece.pieceId,
|
||||||
data,
|
data,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mediaType,
|
mediaType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return attachment;
|
||||||
})),
|
})),
|
||||||
Effect.allWith({ concurrency: "unbounded" }),
|
Effect.allWith({ concurrency: "unbounded" }),
|
||||||
|
Effect.map(Array.filter(Predicate.isNotUndefined)),
|
||||||
|
);
|
||||||
|
|
||||||
|
yield* pipe(
|
||||||
|
{ ...piece, attachments },
|
||||||
|
denormalizePiece,
|
||||||
|
Effect.tap((piece) => Effect.sync(() => pieceCache.setFulfilledSucceed(piece.pieceId, piece))),
|
||||||
);
|
);
|
||||||
})),
|
})),
|
||||||
Effect.allWith({ concurrency: "unbounded" }),
|
Effect.allWith({ concurrency: "unbounded" }),
|
||||||
@@ -359,7 +375,7 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) {
|
|||||||
<DialogTitle>Importuj utwory</DialogTitle>
|
<DialogTitle>Importuj utwory</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-4 py-4">
|
<div className="flex flex-col gap-4 py-4">
|
||||||
{SortedMap.size(attachments) > 0 && (
|
{SortedMap.size(files) > 0 && (
|
||||||
<div className="max-h-[50vh] overflow-y-auto">
|
<div className="max-h-[50vh] overflow-y-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -370,7 +386,7 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{pipe(
|
{pipe(
|
||||||
attachments,
|
files,
|
||||||
SortedMap.entries,
|
SortedMap.entries,
|
||||||
Iterable.map(([name, files]) => (
|
Iterable.map(([name, files]) => (
|
||||||
<TableRow key={name}>
|
<TableRow key={name}>
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
import { Piece, pieceCache, type Repertoire, repertoireCache } from "@/cache";
|
import { denormalizeRepertoire, Piece, pieceCache, type Repertoire, repertoireCache } from "@/cache";
|
||||||
import { client } from "@/client";
|
import { client } from "@/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useCache } from "@/hooks/useCache";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
import { Updater } from "@/hooks/useStore";
|
||||||
import { authors, DEBOUNCE, SAVE_DELAY, saveDelay } from "@/snippets";
|
import { authors, DEBOUNCE, SAVE_DELAY } from "@/snippets";
|
||||||
import { PieceId, RepertoireId } from "common";
|
import { PieceId, RepertoireId } from "common";
|
||||||
import { Array, Cause, Effect, Fiber, Match, Option, pipe, Scope } from "effect";
|
import { Array, Cause, Effect, Fiber, Match, Option, pipe, Scope } from "effect";
|
||||||
import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
|
import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
|
||||||
import { FormEventHandler, useCallback, useId, useRef, useState } from "react";
|
import { FormEventHandler, useId, useRef, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
export function Repertoire() {
|
export function Repertoire() {
|
||||||
|
|
||||||
const id = RepertoireId.make(useParams().repertoireId!);
|
const id = RepertoireId.make(useParams().repertoireId!);
|
||||||
|
|
||||||
const { isLoading, error, data, setData } = useLoading(Effect.uninterruptible(repertoireCache.get(id)), [id]);
|
const { isLoading, error, data } = useCache(repertoireCache, id);
|
||||||
|
|
||||||
const setEntries = useCallback((action: Update<readonly Piece[]>) => {
|
|
||||||
setData!(mapProp("entries", action));
|
|
||||||
Effect.runFork(repertoireCache.invalidate(id));
|
|
||||||
}, [id, setData]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +38,7 @@ export function Repertoire() {
|
|||||||
<h3 className="font-bold">Repertuar</h3>
|
<h3 className="font-bold">Repertuar</h3>
|
||||||
<RepertoireForm repertoire={data} />
|
<RepertoireForm repertoire={data} />
|
||||||
</div>
|
</div>
|
||||||
<Entries repertoire={data} setEntries={setEntries} />
|
<Entries repertoire={data} />
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -94,8 +90,8 @@ function RepertoireForm(props: RepertoireForm.Props) {
|
|||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
||||||
yield* client.deleteRepertoire(props.repertoire.repertoireId);
|
yield* client.deleteRepertoire(props.repertoire.repertoireId);
|
||||||
|
repertoireCache.unset(props.repertoire.repertoireId);
|
||||||
|
|
||||||
yield* repertoireCache.invalidate(props.repertoire.repertoireId);
|
|
||||||
navigate("..");
|
navigate("..");
|
||||||
})).pipe(Effect.runPromise);
|
})).pipe(Effect.runPromise);
|
||||||
|
|
||||||
@@ -129,7 +125,6 @@ function RepertoireForm(props: RepertoireForm.Props) {
|
|||||||
namespace Entries {
|
namespace Entries {
|
||||||
export interface Props {
|
export interface Props {
|
||||||
readonly repertoire: Repertoire;
|
readonly repertoire: Repertoire;
|
||||||
readonly setEntries: Updater<readonly Piece[]>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +150,6 @@ function Entries(props: Entries.Props) {
|
|||||||
repertoire={props.repertoire}
|
repertoire={props.repertoire}
|
||||||
piece={entry}
|
piece={entry}
|
||||||
no={i + 1}
|
no={i + 1}
|
||||||
setEntries={props.setEntries}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -166,7 +160,7 @@ function Entries(props: Entries.Props) {
|
|||||||
<Plus />Dodaj utwór
|
<Plus />Dodaj utwór
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<AddEntryDialogContent repertoire={props.repertoire} setEntries={props.setEntries} setDialogOpen={setDialogOpen} />
|
<AddEntryDialogContent repertoire={props.repertoire} setDialogOpen={setDialogOpen} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -177,7 +171,6 @@ namespace EntryRow {
|
|||||||
readonly repertoire: Repertoire;
|
readonly repertoire: Repertoire;
|
||||||
readonly piece: Piece;
|
readonly piece: Piece;
|
||||||
readonly no: number;
|
readonly no: number;
|
||||||
readonly setEntries: Updater<readonly Piece[]>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +178,6 @@ function EntryRow({
|
|||||||
repertoire,
|
repertoire,
|
||||||
piece,
|
piece,
|
||||||
no,
|
no,
|
||||||
setEntries,
|
|
||||||
}: EntryRow.Props) {
|
}: EntryRow.Props) {
|
||||||
|
|
||||||
const moveUpAction = (entries: readonly Piece[]) => pipe(
|
const moveUpAction = (entries: readonly Piece[]) => pipe(
|
||||||
@@ -211,13 +203,15 @@ function EntryRow({
|
|||||||
|
|
||||||
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
||||||
|
|
||||||
yield* client.updateRepertoire({
|
yield* pipe(
|
||||||
|
client.updateRepertoire({
|
||||||
repertoireId: repertoire.repertoireId,
|
repertoireId: repertoire.repertoireId,
|
||||||
name: repertoire.name,
|
name: repertoire.name,
|
||||||
entries: pipe(repertoire.entries, action, mapToId),
|
entries: pipe(repertoire.entries, action, mapToId),
|
||||||
});
|
}),
|
||||||
|
Effect.flatMap(denormalizeRepertoire),
|
||||||
setEntries(action);
|
Effect.tap((repertoire) => Effect.sync(() => repertoireCache.setFulfilledSucceed(repertoire.repertoireId, repertoire))),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const moveUp = () => Effect.runPromise(update(moveUpAction));
|
const moveUp = () => Effect.runPromise(update(moveUpAction));
|
||||||
@@ -257,7 +251,6 @@ const ADD_ENTRY_DIALOG_LIMIT = 10;
|
|||||||
namespace AddEntryDialogContent {
|
namespace AddEntryDialogContent {
|
||||||
export interface Props {
|
export interface Props {
|
||||||
readonly repertoire: Repertoire;
|
readonly repertoire: Repertoire;
|
||||||
readonly setEntries: Updater<readonly Piece[]>;
|
|
||||||
readonly setDialogOpen: Updater<boolean>;
|
readonly setDialogOpen: Updater<boolean>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,7 +325,6 @@ function AddEntryDialogContent(props: AddEntryDialogContent.Props) {
|
|||||||
key={pieceId}
|
key={pieceId}
|
||||||
repertoire={props.repertoire}
|
repertoire={props.repertoire}
|
||||||
pieceId={pieceId}
|
pieceId={pieceId}
|
||||||
setEntries={props.setEntries}
|
|
||||||
setDialogOpen={props.setDialogOpen}
|
setDialogOpen={props.setDialogOpen}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -348,27 +340,29 @@ namespace EntryDialogPieceRow {
|
|||||||
export interface Props {
|
export interface Props {
|
||||||
readonly repertoire: Repertoire;
|
readonly repertoire: Repertoire;
|
||||||
readonly pieceId: PieceId;
|
readonly pieceId: PieceId;
|
||||||
readonly setEntries: Updater<readonly Piece[]>;
|
|
||||||
readonly setDialogOpen: Updater<boolean>;
|
readonly setDialogOpen: Updater<boolean>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
|
function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
|
||||||
|
|
||||||
const { isLoading, error, data: piece } = useLoading(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
|
const { isLoading, error, data: piece } = useCache(pieceCache, props.pieceId);
|
||||||
|
|
||||||
const onClick = () => Effect.gen(function* () {
|
const onClick = () => Effect.gen(function* () {
|
||||||
|
|
||||||
const action = Array.append(piece!);
|
const action = Array.append(piece!);
|
||||||
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
|
||||||
|
|
||||||
yield* client.updateRepertoire({
|
yield* pipe(
|
||||||
|
client.updateRepertoire({
|
||||||
repertoireId: props.repertoire.repertoireId,
|
repertoireId: props.repertoire.repertoireId,
|
||||||
name: props.repertoire.name,
|
name: props.repertoire.name,
|
||||||
entries: pipe(props.repertoire.entries, action, mapToId),
|
entries: pipe(props.repertoire.entries, action, mapToId),
|
||||||
});
|
}),
|
||||||
|
Effect.flatMap(denormalizeRepertoire),
|
||||||
|
Effect.tap((repertoire) => Effect.sync(() => repertoireCache.setFulfilledSucceed(repertoire.repertoireId, repertoire))),
|
||||||
|
);
|
||||||
|
|
||||||
props.setEntries(action);
|
|
||||||
props.setDialogOpen(false);
|
props.setDialogOpen(false);
|
||||||
}).pipe(Effect.runPromise);
|
}).pipe(Effect.runPromise);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogT
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useCache } from "@/hooks/useCache";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { created, DEBOUNCE, modified } from "@/snippets";
|
import { created, DEBOUNCE, modified } from "@/snippets";
|
||||||
import { RepertoireId } from "common";
|
import { RepertoireId } from "common";
|
||||||
@@ -93,7 +94,7 @@ namespace RepertoireRow {
|
|||||||
|
|
||||||
function RepertoireRow(props: RepertoireRow.Props) {
|
function RepertoireRow(props: RepertoireRow.Props) {
|
||||||
|
|
||||||
const { isLoading, error, data: repertoire } = useLoading(Effect.uninterruptible(repertoireCache.get(props.repertoireId)), [props.repertoireId]);
|
const { isLoading, error, data: repertoire } = useCache(repertoireCache, props.repertoireId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user