JUMBO refactor, still work in progress

This commit is contained in:
2025-10-07 00:14:31 +02:00
parent 3694492e1a
commit dc0ec5c635
50 changed files with 4283 additions and 3698 deletions

View File

@@ -1,13 +1,13 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/style.css",
"baseColor": "stone",
"cssVariables": false,
"cssVariables": true,
"prefix": ""
},
"aliases": {

View File

@@ -4,20 +4,19 @@
"type": "module",
"license": "UNLICENSED",
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"autoprefixer": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"backend": "workspace:^",
"class-variance-authority": "catalog:",
"elysia": "catalog:",
"postcss": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
},
"dependencies": {
"@elysiajs/eden": "catalog:",
"@radix-ui/react-dialog": "catalog:",
"@radix-ui/react-dropdown-menu": "catalog:",
"@radix-ui/react-label": "catalog:",
@@ -31,7 +30,6 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "catalog:",
"tailwind-merge": "catalog:",
"tailwindcss-animate": "catalog:"
"tailwind-merge": "catalog:"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,4 +1,5 @@
import { Home } from "@/routes/Home";
import { Login } from "@/routes/Login";
import { Piece } from "@/routes/Piece";
import { Pieces } from "@/routes/Pieces";
import { Repertoire } from "@/routes/Repertoire";
@@ -62,21 +63,17 @@ const router = createBrowserRouter([
},
],
},
], {
future: {
v7_fetcherPersist: true,
v7_normalizeFormMethod: true,
v7_partialHydration: true,
v7_relativeSplatPath: true,
v7_skipActionErrorRevalidation: true,
{
path: "/login",
Component: Login,
},
});
]);
const rootElement = document.getElementById("root") as HTMLDivElement;
const root = createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
<RouterProvider router={router} />
</StrictMode>,
);

View File

@@ -1,66 +1,21 @@
import type * as Db from "backend/database";
import { AttachmentId, PieceId, RepertoireId, UserId } from "common";
import { Cache, Duration, Effect, Option, pipe } from "effect";
import { client, mapResponse } from "./client";
import { PieceId, RepertoireId, UserId } from "common";
import the_api, { SystemInformation } from "common/the_api";
import { Array, Cache, Duration, Effect, pipe } from "effect";
import { client } from "./client";
export interface User {
readonly userId: UserId;
readonly displayName: string;
}
export interface SystemInformation {
readonly createdBy: Option.Option<User | null>;
readonly createdAt: string;
readonly modifiedBy: Option.Option<User | null>;
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[];
}
export interface Repertoire extends SystemInformation {
readonly repertoireId: RepertoireId;
readonly name: string;
readonly entries: readonly Piece[];
}
interface DbSystemInformation {
readonly createdBy: UserId | null;
readonly createdAt: string;
readonly modifiedBy: UserId | null;
readonly modifiedAt: string | null;
}
export const denormalizeSystemInformation = <T extends DbSystemInformation>({
export const denormalizeSystemInformation = <T extends SystemInformation>({
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,
),
@@ -68,26 +23,19 @@ export const denormalizeSystemInformation = <T extends DbSystemInformation>({
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(
}: typeof the_api.record.getPiece.response.Type) => 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),
);
@@ -95,7 +43,7 @@ export const denormalizePiece = ({
export const denormalizeRepertoire = ({
entries,
...rest
}: Db.Repertoire & { entries: PieceId[] }) => pipe(
}: typeof the_api.record.getRepertoire.response.Type) => pipe(
Effect.all({
entries: Effect.all(entries.map((entry) => Effect.uninterruptible(pieceCache.get(entry))), { concurrency: "unbounded" }),
}, { concurrency: "unbounded" }),
@@ -110,44 +58,38 @@ const UserSemaphore = Effect.unsafeMakeSemaphore(1);
const PieceSemaphore = Effect.unsafeMakeSemaphore(4);
const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
export const userLookup = (userId: UserId) => pipe(
Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })),
Effect.flatMap(mapResponse),
Effect.catchAll((error) => error.status === 404 ? Effect.succeed(null) : Effect.fail(error)),
Effect.map((x): User | null => x), // safely coerce to interface
UserSemaphore.withPermits(1),
);
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
PieceSemaphore.withPermits(1),
);
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),
);
export const userCache = Effect.runSync(Cache.make({
capacity: Infinity,
timeToLive: Duration.days(1),
lookup: userLookup,
lookup: (userId: UserId) => pipe(
client.getUser(userId),
UserSemaphore.withPermits(1),
),
}));
export const pieceCache = Effect.runSync(Cache.make({
capacity: Infinity,
timeToLive: Duration.days(1),
lookup: pieceLookup,
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: repertoireLookup,
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 DenormalizedSystemInformation = Effect.Effect.Success<ReturnType<typeof denormalizeSystemInformation<SystemInformation>>>;

View File

@@ -1,30 +1,6 @@
import { Treaty, treaty } from "@elysiajs/eden";
import type { App } from "backend/app";
import { ACCEPTED_MEDIA_TYPES } from "common/MediaType";
import { Effect } from "effect";
import * as Client from "common/Client";
import the_api from "common/the_api";
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 API_URL_PREFIX = process.env.NODE_ENV === "production" ? undefined : "http://localhost:3000";
export const API_URL_PREFIX = process.env.NODE_ENV === "production" ? "" : "http://localhost:3000";
export const client = treaty<App>(API_URL_PREFIX, {
fetch: {
credentials: "include",
},
keepDomain: true,
onResponse: async (res) => {
const contentType = res.headers.get('Content-Type')?.split(';')[0];
if (contentType !== undefined && ACCEPTED_MEDIA_TYPES.includes(contentType)) {
const blob = await res.blob();
// TODO Decode filename from Content-Disposition header
const file = new File([blob], "", { type: contentType });
return file;
}
},
}).api.v1;
export const mapResponse = <R extends Record<number, unknown>>({ error, data }: Treaty.TreatyResponse<R>): ResponseEffect<R> => {
return error !== null
? Effect.fail(error as Exclude<keyof R, 200> extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200>])
: Effect.succeed(data);
};
export const client = Client.client(the_api, { baseUrl: API_URL_PREFIX });

View File

@@ -1,9 +1,6 @@
import { API_URL_PREFIX } from "@/client";
import { mapProp, Update, Updater } from "@/hooks/useStore";
import { Treaty } from "@elysiajs/eden";
import { Effect, Fiber, pipe } from "effect";
import { Console, Effect, Fiber, pipe } from "effect";
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
export namespace Loading {
export interface Pending {
@@ -34,57 +31,6 @@ export type Loading<A, E> =
| Loading.Error<E>
;
export type ErrorResponses<R extends Record<number, unknown>> =
Exclude<keyof R, 200 | 401> extends never
? { status: unknown, value: unknown }
: { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200 | 401>];
export type LoadingResult<R extends Record<number, unknown>> = Loading<R[200], ErrorResponses<R>>;
export function useLoading<R extends Record<number, unknown>>(fn: () => Promise<Treaty.TreatyResponse<R>>, deps: React.DependencyList) {
const navigate = useNavigate();
const [result, setResult] = useState<LoadingResult<R>>(IS_LOADING);
useEffect(() => {
setResult(IS_LOADING);
let cancelled = false;
fn().then(({ error, data }) => {
if (cancelled) return;
if (error !== null) {
if (error.status === 401) {
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
return;
}
setResult(Object.freeze<Loading.Error<ErrorResponses<R>>>({
isLoading: false,
data: null,
error: error as ErrorResponses<R>,
setData: null,
}));
} else {
setResult({
isLoading: false,
error,
data,
setData: (action) => setResult(mapProp("data", action) as Update<LoadingResult<R>>),
} as LoadingResult<R>);
}
}, (error) => {
console.error(error);
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigate, ...deps]);
return result;
}
const IS_LOADING = Object.freeze({
isLoading: true,
data: null,
@@ -92,10 +38,9 @@ const IS_LOADING = Object.freeze({
setData: null,
});
export function useLoadingEffect<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)), []);
useEffect(() => {
@@ -117,6 +62,7 @@ export function useLoadingEffect<A, E>(effect: Effect.Effect<A, E>, deps: React.
setData: null,
})),
}),
Effect.catchAllDefect(Console.error),
Effect.runFork,
);
const interruptEffect = Fiber.interrupt(fiber);
@@ -125,7 +71,7 @@ export function useLoadingEffect<A, E>(effect: Effect.Effect<A, E>, deps: React.
Effect.runFork(interruptEffect);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setResultEffect, ...deps]);
}, deps);
return result;
}

View File

@@ -1,4 +1,4 @@
import { UserId } from "common";
import { type Me } from "common/the_api";
import { identity } from "effect";
import { useLayoutEffect, useState } from "react";
@@ -9,23 +9,15 @@ export const mapProp = <const K extends string, T>(prop: K, action: Update<T>) =
return Object.freeze({ ...object, [prop]: typeof action === "function" ? (action as (prev: T) => T)(object[prop]) : action });
};
export namespace Store {
export interface User {
readonly userId: UserId;
readonly username: string;
readonly roles: readonly string[];
}
}
export interface Store {
readonly user: Store.User | null;
readonly user: Me | null;
}
let store: Store = Object.freeze<Store>({
user: null,
});
export function setUser(action: Update<Store.User | null>) {
export function setUser(action: Update<Me | null>) {
set(mapProp("user", action));
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="uuid-f8d4d392-7c12-4bd9-baff-66fbf7814b91" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<path d="m3.802,14.032c.388.242,1.033.511,1.715.511.621,0,1.198-.18,1.676-.487,0,0,.001,0,.002-.001l1.805-1.128v4.073c-.286,0-.574-.078-.824-.234l-4.374-2.734Z" fill="#225086"/>
<path d="m7.853,1.507L.353,9.967c-.579.654-.428,1.642.323,2.111,0,0,2.776,1.735,3.126,1.954.388.242,1.033.511,1.715.511.621,0,1.198-.18,1.676-.487,0,0,.001,0,.002-.001l1.805-1.128-4.364-2.728,4.365-4.924V1s0,0,0,0c-.424,0-.847.169-1.147.507Z" fill="#6df"/>
<polygon points="4.636 10.199 4.688 10.231 9 12.927 9.001 12.927 9.001 12.927 9.001 5.276 9 5.275 4.636 10.199" fill="#cbf8ff"/>
<path d="m17.324,12.078c.751-.469.902-1.457.323-2.111l-4.921-5.551c-.397-.185-.842-.291-1.313-.291-.925,0-1.752.399-2.302,1.026l-.109.123h0s4.364,4.924,4.364,4.924h0s0,0,0,0l-4.365,2.728v4.073c.287,0,.573-.078.823-.234l7.5-4.688Z" fill="#074793"/>
<path d="m9.001,1v4.275s.109-.123.109-.123c.55-.627,1.377-1.026,2.302-1.026.472,0,.916.107,1.313.291l-2.579-2.909c-.299-.338-.723-.507-1.146-.507Z" fill="#0294e4"/>
<polygon points="13.365 10.199 13.365 10.199 13.365 10.199 9.001 5.276 9.001 12.926 13.365 10.199" fill="#96bcc2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,6 +1,6 @@
import { client } from "@/client";
import { useLoading } from "@/hooks/useLoading.ts";
import { AttachmentId, PieceId } from "common";
import { AttachmentId } from "common";
import { Match } from "effect";
import JSZip from "jszip";
import { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
@@ -10,10 +10,9 @@ import { useParams } from "react-router-dom";
export default function Attachment() {
const params = useParams();
const pieceId = PieceId(params.pieceId!);
const attachmentId = AttachmentId(params.attachmentId!);
const attachmentId = AttachmentId.make(params.attachmentId!);
const { isLoading, error, data } = useLoading(() => client.piece({ pieceId }).attachment({ attachmentId }).get(), [pieceId, attachmentId]);
const { isLoading, error, data } = useLoading(client.getAttachment(attachmentId), [attachmentId]);
const containerRef = useRef<HTMLDivElement>(null);
const renderFn = useRef<null | (() => void)>(null);
@@ -22,14 +21,14 @@ export default function Attachment() {
if (isLoading || error !== null) return;
let musixXmlBlob: Blob = data;
let musixXmlData: Uint8Array = data.data;
/* If the file is the compressed .mxl file, we do the uncompression
* ourselves, because apparently OpenSheetMusicDisplay is incapable.
*/
if (data.type === "application/vnd.recordare.musicxml") {
if (data.mediaType === "application/vnd.recordare.musicxml") {
const zip = new JSZip();
await zip.loadAsync(data);
await zip.loadAsync(musixXmlData);
const containerFile = zip.file("META-INF/container.xml");
if (containerFile === null) {
@@ -58,10 +57,10 @@ export default function Attachment() {
return;
}
musixXmlBlob = await musicXmlFile.async("blob");
musixXmlData = await musicXmlFile.async("uint8array");
}
const musicXml = await musixXmlBlob.text();
const musicXml = new TextDecoder().decode(musixXmlData);
const osmd = new OpenSheetMusicDisplay(containerRef.current!, {
autoResize: false,
@@ -105,8 +104,10 @@ export default function Attachment() {
<div className="w-full h-full overflow-hidden flex items-center justify-center">
<div>
Wystąpił błąd: {Match.value(error).pipe(
Match.when({ status: 422 }, ({ value }) => value.message),
Match.when({ status: 404 }, () => "Załącznik nie istnieje"),
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
Match.tag("NotFound", () => "Załącznik nie istnieje"),
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
Match.exhaustive,
)}
</div>

View File

@@ -0,0 +1,40 @@
import { API_URL_PREFIX } from "@/client";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import microsoftEntraId from "@/icons/microsoft-entra-id.svg";
export function Login() {
const internalUrl = `${API_URL_PREFIX}/login`;
const externalUrl = `${API_URL_PREFIX}/login?external`;
return (
<div className="w-full h-full flex items-center justify-center">
<Card>
<CardHeader>
<CardTitle>Repozytorium muzyczne</CardTitle>
<CardDescription>Zaloguj się, aby kontynuować</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2 content-stretch max-w-sm">
<div
className="text-sm text-stone-500 dark:text-stone-400"
>
Użyj emaila i hasła, konta Microsoft lub konta Google.
</div>
<a className={buttonVariants()} href={externalUrl}>
Konto zewnętrzne
</a>
<div
className="text-sm text-stone-500 dark:text-stone-400 mt-4"
>
Użyj konta firmowego.
</div>
<a className={buttonVariants()} href={internalUrl}>
<img src={microsoftEntraId} />
<div>Konto firmowe</div>
</a>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,16 +1,17 @@
import { Attachment, denormalizeSystemInformation, type Piece, pieceCache } from "@/cache";
import { Attachment, denormalizeSystemInformation, pieceCache, type Piece } 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 { useLoading } from "@/hooks/useLoading";
import { mapProp, Update, Updater } from "@/hooks/useStore";
import { created, modified, saveDelay } from "@/snippets";
import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets";
import { Label } from "@radix-ui/react-label";
import clsx from "clsx";
import { PieceId } from "common";
import * as Body from "common/Body";
import { getMediaTypeForFilename } from "common/MediaType";
import { Cause, Effect, Option } from "effect";
import { Cause, Clock, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect";
import { constant } from "effect/Function";
import { Download, Loader2, Trash, UploadCloud } from "lucide-react";
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
@@ -18,9 +19,9 @@ import { Link, useNavigate, useParams } from "react-router-dom";
export function Piece() {
const id = PieceId(useParams().pieceId!);
const id = PieceId.make(useParams().pieceId!);
const { isLoading, error, data, setData } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(id)), [id]);
const { isLoading, error, data, setData } = useLoading(Effect.uninterruptible(pieceCache.get(id)), [id]);
const setAttachments = useCallback((action: Update<readonly Attachment[]>) => {
setData!(mapProp("attachments", action));
@@ -38,7 +39,7 @@ export function Piece() {
return (
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
{error !== null ? (
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`
) : (<>
<div className="flex flex-col gap-4 p-4 border rounded">
<h3 className="font-bold text-lg">Utwór</h3>
@@ -82,17 +83,15 @@ function PieceForm(props: PieceForm.Props) {
try {
setIsSaving(true);
const { error } = await client.piece({ pieceId: props.piece.pieceId }).put({
await client.updatePiece({
pieceId: props.piece.pieceId,
name,
composer: composer.length > 0 ? composer : null,
lyricist: lyricist.length > 0 ? lyricist : null,
arranger: arranger.length > 0 ? arranger : null,
});
if (error !== null) {
console.error(error.value);
return;
}
composer: composer.length > 0 ? Option.some(composer) : Option.none(),
lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(),
arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(),
}).pipe(Effect.runPromise);
} catch (error) {
console.error(error);
} finally {
await delay;
setIsSaving(false);
@@ -103,17 +102,14 @@ function PieceForm(props: PieceForm.Props) {
try {
setIsDeleting(true);
const { error } = await client
.piece({ pieceId: props.piece.pieceId })
.delete();
await Effect.runPromise(pipe(
client.deletePiece(props.piece.pieceId),
Effect.andThen(pieceCache.invalidate(props.piece.pieceId)),
));
if (error !== null) {
console.error(error.value);
return;
}
Effect.runFork(pieceCache.invalidate(props.piece.pieceId));
navigate("..");
} catch (error) {
console.error(error);
} finally {
setIsDeleting(false);
}
@@ -214,45 +210,47 @@ namespace AttachmentRow {
function AttachmentRow(props: AttachmentRow.Props) {
const url = `${API_URL_PREFIX}/api/v1/piece/${props.attachment.pieceId}/attachment/${props.attachment.attachmentId}`;
const url = `${API_URL_PREFIX}/api/piece/${props.attachment.pieceId}/attachment/${props.attachment.attachmentId}`;
const open = useCallback(async (event: MouseEvent<HTMLAnchorElement>) => {
const download = () => Effect.gen(function* () {
const { data, mediaType, filename } = yield* client.getAttachment(props.attachment.attachmentId);
const file = new File([data], filename, {
type: mediaType,
lastModified: pipe(
props.attachment.modifiedAt,
Option.getOrElse(() => props.attachment.createdAt),
DateTime.toEpochMillis,
),
});
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}).pipe(Effect.runPromise);
const open = (event: MouseEvent<HTMLAnchorElement>) => Effect.gen(function* () {
if (props.attachment.mediaType !== "application/pdf") {
return;
}
event.preventDefault();
const { error, data } = await client
.piece({ pieceId: props.attachment.pieceId })
.attachment({ attachmentId: props.attachment.attachmentId })
.get();
const { data, mediaType } = yield* client.getAttachment(props.attachment.attachmentId);
const blob = new Blob([data], { type: mediaType });
if (error !== null) {
console.error(error.value);
return;
}
const url = URL.createObjectURL(data);
const url = URL.createObjectURL(blob);
window.open(url, "_target");
URL.revokeObjectURL(url);
}, [props.attachment.attachmentId, props.attachment.mediaType, props.attachment.pieceId]);
const doDelete = useCallback(async () => {
const { error } = await client
.piece({ pieceId: props.attachment.pieceId })
.attachment({ attachmentId: props.attachment.attachmentId })
.delete();
if (error !== null) {
console.error(error.value);
return;
}
}).pipe(Effect.runPromise);
const doDelete = () => Effect.gen(function* () {
yield* client.deleteAttachment(props.attachment.attachmentId);
props.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId));
}, [props]);
}).pipe(Effect.runPromise);
return (
<TableRow>
@@ -277,9 +275,9 @@ function AttachmentRow(props: AttachmentRow.Props) {
{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}>
<Button type="button" variant="ghost" size="icon" title="Pobierz" onClick={download}>
<Download />
</a>
</Button>
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={doDelete}>
<Trash />
</Button>
@@ -304,15 +302,22 @@ function AttachmentForm(props: AttachmentForm.Props) {
e.dataTransfer.dropEffect = "copy";
};
const onDrop: DragEventHandler<HTMLElement> = async (e) => {
const onDrop: DragEventHandler<HTMLElement> = (e) => Effect.gen(function* () {
e.preventDefault();
if (isLoading) {
return;
}
const delay = saveDelay();
try {
const delay = yield* Effect.fork(SAVE_DELAY);
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
yield* Scope.addFinalizer(scope, Effect.gen(function* () {
yield* Fiber.join(delay);
setIsLoading(false);
}));
setIsLoading(true);
for (const file of e.dataTransfer.files) {
@@ -321,18 +326,21 @@ function AttachmentForm(props: AttachmentForm.Props) {
continue;
}
const { data, error } = await client.piece({ pieceId: props.pieceId }).attachment.post({
const data = yield* Body.bytes(file);
const exit = yield* Effect.exit(client.createAttachment({
pieceId: props.pieceId,
data,
filename: file.name,
mediaType,
data: file,
});
}));
if (error !== null) {
console.error(error.value);
if (Exit.isFailure(exit)) {
console.error(exit.cause);
continue;
}
const attachment = await Effect.runPromise(denormalizeSystemInformation(data));
const attachment = yield* denormalizeSystemInformation(exit.exitValue);
props.setAttachments((prev) => {
const next = [...prev, attachment];
@@ -340,11 +348,8 @@ function AttachmentForm(props: AttachmentForm.Props) {
return next;
});
}
} finally {
await delay;
setIsLoading(false);
}
};
}));
}).pipe(Effect.runPromise);
return (
<div

View File

@@ -5,10 +5,10 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogT
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 { useLoading } from "@/hooks/useLoading";
import { authors, created, DEBOUNCE, modified } from "@/snippets";
import { PieceId } from "common";
import { Cause, Effect, Match } from "effect";
import { Cause, Effect, Match, Option, Scope } from "effect";
import { Loader2, Plus } from "lucide-react";
import { FormEventHandler, useId, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
@@ -20,21 +20,15 @@ export function Pieces() {
const debounce = useRef(Effect.void);
const { isLoading, error, data: pieceIds } = useLoadingEffect(Effect.gen(function* () {
const { isLoading, error, data: pieceIds } = useLoading(Effect.gen(function* () {
yield* debounce.current;
const { error, data } = yield* Effect.promise((signal) => client.piece.get({
query: {
...(name !== "" ? { name } : undefined),
...(author !== "" ? { author } : undefined),
},
fetch: { signal },
}));
if (error !== null) {
return yield* Effect.fail(error);
} else {
return data;
}
const data = yield* client.queryPieces({
name: name !== "" ? Option.some(name) : Option.none(),
author: author !== "" ? Option.some(author) : Option.none(),
offset: 0,
limit: 100,
});
return data;
}), [name, author]);
return (
@@ -91,7 +85,7 @@ export function Pieces() {
) : error !== null ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`}
</TableCell>
</TableRow>
) : (
@@ -111,7 +105,7 @@ namespace PieceRow {
function PieceRow(props: PieceRow.Props) {
const { isLoading, error, data: piece } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
const { isLoading, error, data: piece } = useLoading(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
if (isLoading) {
return (
@@ -126,9 +120,10 @@ function PieceRow(props: PieceRow.Props) {
<TableRow>
<TableCell colSpan={4}>
Wystąpił błąd: {Match.value(error).pipe(
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
Match.when({ status: 422 }, ({ value }) => value.message),
Match.when({ status: 404 }, () => "Utwór nie istnieje"),
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
Match.tag("NotFound", () => "Utwór nie istnieje"),
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
Match.exhaustive,
)}
</TableCell>
@@ -170,29 +165,25 @@ function AddPieceDialogContent() {
const [isLoading, setIsLoading] = useState(false);
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
const onSubmit: FormEventHandler<HTMLFormElement> = (e) => Effect.gen(function* () {
e.preventDefault();
try {
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
yield* Scope.addFinalizer(scope, Effect.sync(() => setIsLoading(false)));
setIsLoading(true);
const { data, error } = await client.piece.post({
const { pieceId } = yield* client.createPiece({
name,
composer: composer.length > 0 ? composer : null,
lyricist: lyricist.length > 0 ? lyricist : null,
arranger: arranger.length > 0 ? arranger : null,
composer: composer.length > 0 ? Option.some(composer) : Option.none(),
lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(),
arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(),
});
if (error !== null) {
console.error(error.value);
return;
}
navigate(data.pieceId);
} finally {
setIsLoading(false);
}
};
navigate(pieceId);
}));
}).pipe(Effect.runPromise);
return (
<DialogContent>

View File

@@ -5,20 +5,20 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
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 { useLoading } from "@/hooks/useLoading";
import { mapProp, Update, Updater } from "@/hooks/useStore";
import { authors, DEBOUNCE, saveDelay } from "@/snippets";
import { authors, DEBOUNCE, SAVE_DELAY, saveDelay } from "@/snippets";
import { PieceId, RepertoireId } from "common";
import { Array, Cause, Effect, Match, Option, pipe } from "effect";
import { Array, Cause, Effect, Fiber, Match, Option, pipe, Scope } from "effect";
import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
import { FormEventHandler, useCallback, useId, useMemo, useRef, useState } from "react";
import { FormEventHandler, useCallback, useId, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
export function Repertoire() {
const id = RepertoireId(useParams().repertoireId!);
const id = RepertoireId.make(useParams().repertoireId!);
const { isLoading, error, data, setData } = useLoadingEffect(Effect.uninterruptible(repertoireCache.get(id)), [id]);
const { isLoading, error, data, setData } = useLoading(Effect.uninterruptible(repertoireCache.get(id)), [id]);
const setEntries = useCallback((action: Update<readonly Piece[]>) => {
setData!(mapProp("entries", action));
@@ -36,7 +36,7 @@ export function Repertoire() {
return (
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
{error !== null ? (
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`
) : (<>
<div className="flex flex-col gap-4 p-4 border rounded">
<h3 className="font-bold">Repertuar</h3>
@@ -65,47 +65,39 @@ function RepertoireForm(props: RepertoireForm.Props) {
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => Effect.gen(function* () {
e.preventDefault();
const delay = saveDelay();
try {
const delay = yield* Effect.fork(SAVE_DELAY);
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
yield* Scope.addFinalizer(scope, Effect.gen(function* () {
yield* Fiber.join(delay);
setIsSaving(false);
}));
setIsSaving(true);
const { error } = await client.repertoire({ repertoireId: props.repertoire.repertoireId }).put({
yield* client.updateRepertoire({
repertoireId: props.repertoire.repertoireId,
name,
entries: props.repertoire.entries.map(({ pieceId }) => pieceId),
});
}));
}).pipe(Effect.runPromise);
if (error !== null) {
console.error(error.value);
return;
}
} finally {
await delay;
setIsSaving(false);
}
};
const doDelete = () => Effect.scopedWith((scope) => Effect.gen(function* () {
const doDelete = useCallback(async () => {
try {
setIsDeleting(true);
yield* Scope.addFinalizer(scope, Effect.sync(() => setIsSaving(false)));
const { error } = await client
.repertoire({ repertoireId: props.repertoire.repertoireId })
.delete();
setIsDeleting(true);
if (error !== null) {
console.error(error.value);
return;
}
yield* client.deleteRepertoire(props.repertoire.repertoireId);
Effect.runFork(repertoireCache.invalidate(props.repertoire.repertoireId));
navigate("..");
} finally {
setIsDeleting(false);
}
}, [props.repertoire.repertoireId, navigate]);
yield* repertoireCache.invalidate(props.repertoire.repertoireId);
navigate("..");
})).pipe(Effect.runPromise);
return (
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
@@ -196,47 +188,41 @@ function EntryRow({
setEntries,
}: EntryRow.Props) {
const moveUpAction = useCallback((entries: readonly Piece[]) => pipe(
const moveUpAction = (entries: readonly Piece[]) => pipe(
entries,
Array.remove(no - 1),
Array.insertAt(no - 2, piece),
Option.getOrThrow,
), [no, piece]);
);
const moveDownAction = useCallback((entries: readonly Piece[]) => pipe(
const moveDownAction = (entries: readonly Piece[]) => pipe(
entries,
Array.remove(no - 1),
Array.insertAt(no, piece),
Option.getOrThrow,
), [no, piece]);
);
const removeAction = useCallback((entries: readonly Piece[]) => pipe(
const removeAction = (entries: readonly Piece[]) => pipe(
entries,
Array.filter((p) => p.pieceId !== piece.pieceId),
), [piece.pieceId]);
);
const update = useCallback(async (action: (prev: readonly Piece[]) => readonly Piece[]) => {
const update = (action: (prev: readonly Piece[]) => readonly Piece[]) => Effect.gen(function* () {
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
const { error } = await client
.repertoire({ repertoireId: repertoire.repertoireId })
.put({
name: repertoire.name,
entries: pipe(repertoire.entries, action, mapToId),
});
if (error !== null) {
console.error(error.value);
return;
}
yield* client.updateRepertoire({
repertoireId: repertoire.repertoireId,
name: repertoire.name,
entries: pipe(repertoire.entries, action, mapToId),
});
setEntries(action);
}, [repertoire.entries, repertoire.name, repertoire.repertoireId, setEntries]);
});
const moveUp = useMemo(() => update.bind(undefined, moveUpAction), [moveUpAction, update]);
const moveDown = useMemo(() => update.bind(undefined, moveDownAction), [moveDownAction, update]);
const remove = useMemo(() => update.bind(undefined, removeAction), [removeAction, update]);
const moveUp = () => Effect.runPromise(update(moveUpAction));
const moveDown = () => Effect.runPromise(update(moveDownAction));
const remove = () => Effect.runPromise(update(removeAction));
return (
<TableRow>
@@ -283,22 +269,16 @@ function AddEntryDialogContent(props: AddEntryDialogContent.Props) {
const debounce = useRef(Effect.void);
const { isLoading, error, data: pieceIds } = useLoadingEffect(Effect.gen(function* () {
const { isLoading, error, data: pieceIds } = useLoading(Effect.gen(function* () {
yield* debounce.current;
const { error, data } = yield* Effect.promise((signal) => client.piece.get({
query: {
...(name !== "" ? { name } : undefined),
...(author !== "" ? { author } : undefined),
limit: ADD_ENTRY_DIALOG_LIMIT,
},
fetch: { signal },
}));
const data = yield* client.queryPieces({
name: name !== "" ? Option.some(name) : Option.none(),
author: author !== "" ? Option.some(author) : Option.none(),
offset: 0,
limit: ADD_ENTRY_DIALOG_LIMIT,
});
if (error !== null) {
return yield* Effect.fail(error);
} else {
return data;
}
return data;
}), [name, author]);
return (
@@ -343,7 +323,7 @@ function AddEntryDialogContent(props: AddEntryDialogContent.Props) {
) : error !== null ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`}
</TableCell>
</TableRow>
) : (
@@ -375,29 +355,22 @@ namespace EntryDialogPieceRow {
function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
const { isLoading, error, data: piece } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
const { isLoading, error, data: piece } = useLoading(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
const onClick = useCallback(async () => {
const onClick = () => Effect.gen(function* () {
const action = Array.append(piece!);
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
const { error } = await client
.repertoire({ repertoireId: props.repertoire.repertoireId })
.put({
name: props.repertoire.name,
entries: pipe(props.repertoire.entries, action, mapToId),
});
if (error !== null) {
console.error(error.value);
return;
}
yield* client.updateRepertoire({
repertoireId: props.repertoire.repertoireId,
name: props.repertoire.name,
entries: pipe(props.repertoire.entries, action, mapToId),
});
props.setEntries(action);
props.setDialogOpen(false);
}, [piece, props]);
}).pipe(Effect.runPromise);
if (isLoading) {
return (
@@ -412,9 +385,10 @@ function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
<TableRow>
<TableCell colSpan={2}>
Wystąpił błąd: {Match.value(error).pipe(
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
Match.when({ status: 422 }, ({ value }) => value.message),
Match.when({ status: 404 }, () => "Utwór nie istnieje"),
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
Match.tag("NotFound", () => "Repertuar nie istnieje"),
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
Match.exhaustive,
)}
</TableCell>

View File

@@ -5,10 +5,10 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogT
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 { useLoading } from "@/hooks/useLoading";
import { created, DEBOUNCE, modified } from "@/snippets";
import { RepertoireId } from "common";
import { Cause, Effect, Match } from "effect";
import { Cause, Effect, Match, Option, Scope } from "effect";
import { Loader2, Plus } from "lucide-react";
import { FormEventHandler, ReactNode, useId, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
@@ -19,20 +19,14 @@ export function Repertoires() {
const debounce = useRef(Effect.void);
const { isLoading, error, data: repertoireIds } = useLoadingEffect(Effect.gen(function* () {
const { isLoading, error, data: repertoireIds } = useLoading(Effect.gen(function* () {
yield* debounce.current;
const { error, data } = yield* Effect.promise((signal) => client.repertoire.get({
query: {
...(name !== "" ? { name } : undefined),
},
fetch: { signal },
}));
if (error !== null) {
return yield* Effect.fail(error);
} else {
return data;
}
const data = yield* client.queryRepertoire({
name: name !== "" ? Option.some(name) : Option.none(),
offset: 0,
limit: 100,
});
return data;
}), [name]);
return (
@@ -79,7 +73,7 @@ export function Repertoires() {
) : error !== null ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error)}`}
</TableCell>
</TableRow>
) : (
@@ -99,7 +93,7 @@ namespace RepertoireRow {
function RepertoireRow(props: RepertoireRow.Props) {
const { isLoading, error, data: repertoire } = useLoadingEffect(Effect.uninterruptible(repertoireCache.get(props.repertoireId)), [props.repertoireId]);
const { isLoading, error, data: repertoire } = useLoading(Effect.uninterruptible(repertoireCache.get(props.repertoireId)), [props.repertoireId]);
if (isLoading) {
return (
@@ -114,9 +108,10 @@ function RepertoireRow(props: RepertoireRow.Props) {
<TableRow>
<TableCell colSpan={4}>
Wystąpił błąd: {Match.value(error).pipe(
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
Match.when({ status: 422 }, ({ value }) => value.message),
Match.when({ status: 404 }, () => "Repertuar nie istnieje"),
Match.tag("FetchError", () => "Nie można połączyć się z serwerem"),
Match.tag("NotFound", () => "Repertuar nie istnieje"),
Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"),
Match.tag("Unauthorized", () => "Nie posiadasz uprawnień"),
Match.exhaustive,
)}
</TableCell>
@@ -166,27 +161,23 @@ function AddRepertoireDialogContent() {
const [isLoading, setIsLoading] = useState(false);
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
const onSubmit: FormEventHandler<HTMLFormElement> = (e) => Effect.gen(function* () {
e.preventDefault();
try {
yield* Effect.scopedWith((scope) => Effect.gen(function* () {
yield* Scope.addFinalizer(scope, Effect.sync(() => setIsLoading(false)));
setIsLoading(true);
const { data, error } = await client.repertoire.post({
const { repertoireId } = yield* client.createRepertoire({
name,
entries: [],
});
if (error !== null) {
console.error(error.value);
return;
}
navigate(data.repertoireId);
} finally {
setIsLoading(false);
}
};
navigate(repertoireId);
}));
}).pipe(Effect.runPromise);
return (
<DialogContent>

View File

@@ -1,30 +1,40 @@
import { API_URL_PREFIX, client } from "@/client";
import { client } from "@/client";
import { Button, buttonVariants } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { setUser, useStore } from "@/hooks/useStore";
import { Settings, User } from "lucide-react";
import { Effect, pipe } from "effect";
import { LogOut, Settings, User } from "lucide-react";
import { useEffect } from "react";
import { Link, Outlet } from "react-router-dom";
import { Link, Outlet, useNavigate } from "react-router-dom";
export function Root() {
const navigate = useNavigate();
const user = useStore(state => state.user);
const init = async () => {
const init = Effect.gen(function* () {
if (user !== null) return;
const { data, error } = await client.me.get();
if (error !== null) {
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
return;
}
const data = yield* pipe(
client.me(),
Effect.tapErrorTag("Unauthenticated", () => Effect.sync(() => {
navigate("/login");
})),
);
setUser(data);
};
});
const onLogoutClick = () => Effect.gen(function* () {
yield* client.logout();
setUser(null);
navigate("/login");
}).pipe(Effect.runPromise);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => void init(), []);
useEffect(() => void Effect.runFork(init), []);
if (user === null) {
return (
@@ -43,7 +53,7 @@ export function Root() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline">
<User />{user.username}
<User />{user.displayName}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -52,6 +62,9 @@ export function Root() {
<Settings />Ustawienia
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={onLogoutClick}>
<LogOut />Wyloguj się
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -1,6 +1,7 @@
export function Settings() {
return (
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
Jakby były ustawienia, to by tu były.
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { Piece, SystemInformation } from "@/cache";
import { Piece, DenormalizedSystemInformation } from "@/cache";
import { timeout } from "@/lib/utils";
import { Clock, Duration, Option } from "effect";
import { Clock, DateTime, Duration, Option } from "effect";
import { ReactNode } from "react";
export const DEBOUNCE = Clock.sleep(Duration.millis(250));
export const SAVE_DELAY = Clock.sleep(Duration.millis(250));
export const saveDelay = () => timeout(250);
@@ -30,9 +31,9 @@ export function authors(piece: Piece): ReactNode {
return nodes.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x]);
}
export function created({ createdAt, createdBy }: SystemInformation): ReactNode {
export function created({ createdAt, createdBy }: DenormalizedSystemInformation): ReactNode {
const nodes: ReactNode[] = [createdAt];
const nodes: ReactNode[] = [DateTime.formatLocal(createdAt)];
if (Option.isSome(createdBy)) {
nodes.push(<br />);
@@ -46,7 +47,7 @@ export function created({ createdAt, createdBy }: SystemInformation): ReactNode
return nodes;
}
export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNode {
export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformation): ReactNode {
if (Option.isNone(modifiedAt)) {
if (Option.isNone(modifiedBy)) {
@@ -60,7 +61,7 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo
}
}
const nodes: ReactNode[] = [modifiedAt.value];
const nodes: ReactNode[] = [DateTime.formatLocal(modifiedAt.value)];
if (Option.isSome(modifiedBy)) {
nodes.push(<br />);

View File

@@ -1,9 +1,123 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--font-sans: Lato, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--accent-foreground: oklch(0.216 0.006 56.043);
--accent: oklch(0.97 0.001 106.424);
--background: oklch(1 0 0);
--border: oklch(0.923 0.003 48.717);
--card-foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--destructive: oklch(0.577 0.245 27.325);
--foreground: oklch(0.147 0.004 49.25);
--input: oklch(0.923 0.003 48.717);
--muted-foreground: oklch(0.553 0.013 58.071);
--muted: oklch(0.97 0.001 106.424);
--popover-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--primary-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.216 0.006 56.043);
--radius: 0.625rem;
--ring: oklch(0.709 0.01 56.259);
--secondary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-ring: oklch(0.709 0.01 56.259);
--sidebar: oklch(0.985 0.001 106.423);
}
.dark {
--accent-foreground: oklch(0.985 0.001 106.423);
--accent: oklch(0.268 0.007 34.298);
--background: oklch(0.147 0.004 49.25);
--border: oklch(1 0 0 / 10%);
--card-foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--destructive: oklch(0.704 0.191 22.216);
--foreground: oklch(0.985 0.001 106.423);
--input: oklch(1 0 0 / 15%);
--muted-foreground: oklch(0.709 0.01 56.259);
--muted: oklch(0.268 0.007 34.298);
--popover-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.216 0.006 56.043);
--primary: oklch(0.923 0.003 48.717);
--ring: oklch(0.553 0.013 58.071);
--secondary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-ring: oklch(0.553 0.013 58.071);
--sidebar: oklch(0.216 0.006 56.043);
}
@theme inline {
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-background: var(--background);
--color-border: var(--border);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-destructive: var(--destructive);
--color-foreground: var(--foreground);
--color-input: var(--input);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-ring: var(--ring);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar: var(--sidebar);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
:root {
--radius: 0.5rem;
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,25 +0,0 @@
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import("tailwindcss").Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
fontFamily: {
sans: ["Lato", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {},
},
},
plugins: [tailwindcssAnimate],
};

View File

@@ -1,9 +1,23 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite";
const ReactCompilerConfig = {
target: "19",
};
export default defineConfig({
plugins: [react()],
plugins: [
tailwindcss(),
react({
babel: {
plugins: [
["babel-plugin-react-compiler", ReactCompilerConfig],
],
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),