JUMBO refactor, still work in progress
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>>>;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
9
packages/frontend/src/icons/microsoft-entra-id.svg
Normal file
9
packages/frontend/src/icons/microsoft-entra-id.svg
Normal 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 |
@@ -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>
|
||||
|
||||
40
packages/frontend/src/routes/Login.tsx
Normal file
40
packages/frontend/src/routes/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user