Deduplicate, don't debounce on first visit
This commit is contained in:
@@ -1,21 +1,35 @@
|
||||
import { ListMusic, Music3 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ReactNode } from "react";
|
||||
import { Link, To } from "react-router-dom";
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<div className="p-4 overflow-y-auto flex flex-wrap items-start justify-center gap-4">
|
||||
<Link to="piece">
|
||||
<div className="w-64 h-64 p-4 flex flex-col items-center justify-center gap-4 rounded-md text-lg font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-stone-950 dark:focus-visible:ring-stone-300 border border-stone-200 bg-white hover:bg-stone-100 hover:text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:hover:bg-stone-800 dark:hover:text-stone-50">
|
||||
<Music3 size={32} absoluteStrokeWidth />
|
||||
<div>Utwory</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="repertoire">
|
||||
<div className="w-64 h-64 p-4 flex flex-col items-center justify-center gap-4 rounded-md text-lg font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-stone-950 dark:focus-visible:ring-stone-300 border border-stone-200 bg-white hover:bg-stone-100 hover:text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:hover:bg-stone-800 dark:hover:text-stone-50">
|
||||
<ListMusic size={32} absoluteStrokeWidth />
|
||||
<div>Repertuary</div>
|
||||
</div>
|
||||
</Link>
|
||||
<LinkCard to="piece">
|
||||
<Music3 size={32} absoluteStrokeWidth />
|
||||
<div>Utwory</div>
|
||||
</LinkCard>
|
||||
<LinkCard to="repertoire">
|
||||
<ListMusic size={32} absoluteStrokeWidth />
|
||||
<div>Repertuary</div>
|
||||
</LinkCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
namespace LinkCard {
|
||||
export interface Props {
|
||||
readonly to: To;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
}
|
||||
|
||||
function LinkCard(props: LinkCard.Props) {
|
||||
return (
|
||||
<Link to={props.to}>
|
||||
<div className="w-64 h-64 p-4 flex flex-col items-center justify-center gap-4 rounded-md text-lg font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-stone-950 dark:focus-visible:ring-stone-300 border border-stone-200 bg-white hover:bg-stone-100 hover:text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:hover:bg-stone-800 dark:hover:text-stone-50">
|
||||
{props.children}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Attachment, denormalizeSystemInformation, type Piece, pieceCache, User } from "@/cache";
|
||||
import { Attachment, denormalizeSystemInformation, type Piece, pieceCache } from "@/cache";
|
||||
import { API_URL_PREFIX, client } from "@/client";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useLoadingEffect } from "@/hooks/useLoading";
|
||||
import { mapProp, Update, Updater } from "@/hooks/useStore";
|
||||
import { timeout } from "@/lib/utils";
|
||||
import { created, modified, saveDelay } from "@/snippets";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import clsx from "clsx";
|
||||
import { PieceId } from "common";
|
||||
@@ -78,7 +78,7 @@ function PieceForm(props: PieceForm.Props) {
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const delay = timeout(250);
|
||||
const delay = saveDelay();
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
@@ -271,14 +271,10 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
||||
{props.attachment.mediaType}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs">
|
||||
{props.attachment.createdAt}
|
||||
{Option.isSome(props.attachment.createdBy) && <><br />przez {props.attachment.createdBy.value.username}</>}
|
||||
{created(props.attachment)}
|
||||
</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}</>}
|
||||
{modified(props.attachment)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center flex justify-center gap-4">
|
||||
<a href={url} className={buttonVariants({ variant: "ghost", size: "icon" })} title="Pobierz" download={props.attachment.filename}>
|
||||
@@ -315,7 +311,7 @@ function AttachmentForm(props: AttachmentForm.Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = timeout(250);
|
||||
const delay = saveDelay();
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pieceCache, User } from "@/cache";
|
||||
import { pieceCache } from "@/cache";
|
||||
import { client } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
@@ -6,10 +6,11 @@ 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 { authors, created, DEBOUNCE, modified } from "@/snippets";
|
||||
import { PieceId } from "common";
|
||||
import { Cause, Clock, Duration, Effect, Option } from "effect";
|
||||
import { Cause, Effect } from "effect";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { FormEventHandler, ReactNode, useId, useState } from "react";
|
||||
import { FormEventHandler, useId, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
export function Pieces() {
|
||||
@@ -17,8 +18,10 @@ export function Pieces() {
|
||||
const [name, setName] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
|
||||
const debounce = useRef(Effect.void);
|
||||
|
||||
const { isLoading, error, data: pieceIds } = useLoadingEffect(Effect.gen(function* () {
|
||||
yield* Clock.sleep(Duration.millis(500));
|
||||
yield* debounce.current;
|
||||
const { error, data } = yield* Effect.promise((signal) => client.piece.get({
|
||||
query: {
|
||||
...(name !== "" ? { name } : undefined),
|
||||
@@ -50,14 +53,20 @@ export function Pieces() {
|
||||
type="text"
|
||||
placeholder="Tytuł"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
debounce.current = DEBOUNCE;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="w-[32ch]"
|
||||
type="text"
|
||||
placeholder="Twórcy"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAuthor(e.target.value);
|
||||
debounce.current = DEBOUNCE;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
@@ -120,30 +129,19 @@ function PieceRow(props: PieceRow.Props) {
|
||||
);
|
||||
}
|
||||
|
||||
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.pieceId}>{piece.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{...composerParts.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x])}
|
||||
{authors(piece)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs">
|
||||
{piece.createdAt}
|
||||
{Option.isSome(piece.createdBy) && <><br />przez {piece.createdBy.value.username}</>}
|
||||
{created(piece)}
|
||||
</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}</>}
|
||||
{modified(piece)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,11 @@ 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, Updater } from "@/hooks/useStore";
|
||||
import { timeout } from "@/lib/utils";
|
||||
import { authors, saveDelay } from "@/snippets";
|
||||
import { PieceId, RepertoireId } from "common";
|
||||
import { Array, Cause, Effect, Option, pipe } from "effect";
|
||||
import { Array, Cause, Effect, pipe } from "effect";
|
||||
import { CircleMinus, Loader2 } from "lucide-react";
|
||||
import { FormEventHandler, ReactNode, useCallback, useId, useState } from "react";
|
||||
import { FormEventHandler, useCallback, useId, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
export function Repertoire() {
|
||||
@@ -67,7 +67,7 @@ function RepertoireForm(props: RepertoireForm.Props) {
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const delay = timeout(250);
|
||||
const delay = saveDelay();
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
@@ -200,13 +200,6 @@ function EntryRow(props: EntryRow.Props) {
|
||||
|
||||
}, [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(<em>Nieznani</em>);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
@@ -216,7 +209,7 @@ function EntryRow(props: EntryRow.Props) {
|
||||
{props.piece.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{...composerParts.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x])}
|
||||
{authors(props.piece)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center flex justify-center gap-4">
|
||||
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={remove}>
|
||||
|
||||
@@ -6,18 +6,21 @@ 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 { created, DEBOUNCE, modified } from "@/snippets";
|
||||
import { RepertoireId } from "common";
|
||||
import { Cause, Clock, Duration, Effect, Option } from "effect";
|
||||
import { Cause, Effect, Option } from "effect";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { FormEventHandler, ReactNode, useId, useState } from "react";
|
||||
import { FormEventHandler, ReactNode, useId, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
export function Repertoires() {
|
||||
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const debounce = useRef(Effect.void);
|
||||
|
||||
const { isLoading, error, data: repertoireIds } = useLoadingEffect(Effect.gen(function* () {
|
||||
yield* Clock.sleep(Duration.millis(500));
|
||||
yield* debounce.current;
|
||||
const { error, data } = yield* Effect.promise((signal) => client.repertoire.get({
|
||||
query: {
|
||||
...(name !== "" ? { name } : undefined),
|
||||
@@ -48,7 +51,10 @@ export function Repertoires() {
|
||||
type="text"
|
||||
placeholder="Nazwa"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
debounce.current = DEBOUNCE;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
@@ -134,14 +140,10 @@ function RepertoireRow(props: RepertoireRow.Props) {
|
||||
{...piecesParts}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs">
|
||||
{repertoire.createdAt}
|
||||
{Option.isSome(repertoire.createdBy) && <><br />przez {repertoire.createdBy.value.username}</>}
|
||||
{created(repertoire)}
|
||||
</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}</>}
|
||||
{modified(repertoire)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
63
packages/frontend/src/snippets.tsx
Normal file
63
packages/frontend/src/snippets.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Piece, SystemInformation } from "@/cache";
|
||||
import { timeout } from "@/lib/utils";
|
||||
import { Clock, Duration, Option } from "effect";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const DEBOUNCE = Clock.sleep(Duration.millis(250));
|
||||
|
||||
export const saveDelay = () => timeout(250);
|
||||
|
||||
export function authors(piece: Piece): ReactNode {
|
||||
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (Option.isSome(piece.composer)) {
|
||||
nodes.push(piece.composer.value);
|
||||
}
|
||||
|
||||
if (Option.isSome(piece.arranger)) {
|
||||
nodes.push(`opracowanie: ${piece.arranger.value}`);
|
||||
}
|
||||
|
||||
if (Option.isSome(piece.lyricist)) {
|
||||
nodes.push(`słowa: ${piece.lyricist.value}`);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return <em>Nieznani</em>;
|
||||
}
|
||||
|
||||
return nodes.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x]);
|
||||
}
|
||||
|
||||
export function created({ createdAt, createdBy }: SystemInformation): ReactNode {
|
||||
|
||||
const nodes: ReactNode[] = [createdAt];
|
||||
|
||||
if (Option.isSome(createdBy)) {
|
||||
nodes.push(<br />);
|
||||
nodes.push(`przez ${createdBy.value.username}`);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNode {
|
||||
|
||||
if (Option.isNone(modifiedAt)) {
|
||||
if (Option.isNone(modifiedBy)) {
|
||||
return "\u2014";
|
||||
} else {
|
||||
return `przez ${modifiedBy.value.username}`;
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: ReactNode[] = [modifiedAt.value];
|
||||
|
||||
if (Option.isSome(modifiedBy)) {
|
||||
nodes.push(<br />);
|
||||
nodes.push(`przez ${modifiedBy.value.username}`);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
Reference in New Issue
Block a user