diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index aed6947..24e7537 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,8 +1,8 @@ import cors from "@elysiajs/cors"; import { swagger } from "@elysiajs/swagger"; -import { AttachmentId, PieceId, RequestId, SessionId, Sha256 } from "common"; +import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common"; import * as Function from "common/Function"; -import { Elysia, error, form, t } from "elysia"; +import { Elysia, error, t } from "elysia"; import { sql } from "kysely"; import { generateSessionId, initDatabase } from "./database"; @@ -148,6 +148,35 @@ const app = new Elysia() .execute(); }) + // --- MARK: USER MANAGEMENT ----------------------------------------------- + + .get("/user/:userId", async ({ db, params: { userId }, user }) => { + + if (user === null) { + return error("Unauthorized"); + } + + const res = await db + .selectFrom("User") + .select(["userId", "username", "admin"]) + .where("userId", "=", userId) + .executeTakeFirst(); + + if (res === undefined) { + return error("Not Found"); + } + + return { + userId: res.userId, + username: res.username, + admin: res.admin !== 0, + }; + }, { + params: t.Object({ + userId: tbranded(), + }), + }) + // --- MARK: PIECE CRUD ---------------------------------------------------- .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => { @@ -182,19 +211,66 @@ const app = new Elysia() let q = db .selectFrom("Piece") - .selectAll() + .select("pieceId") .orderBy(["name", "composer", "arranger"]) - .limit(100); + .offset(query.offset ?? 0) + .limit(query.limit ?? 100); - if (query.id !== undefined) { - q = q.where("pieceId", "=", query.id); + if (query.name !== undefined) { + q = q.where("name", "like", "%" + query.name + "%"); + } + + if (query.author !== undefined) { + q = q.where((eb) => eb.or([ + eb("composer", "like", "%" + query.author + "%"), + eb("arranger", "like", "%" + query.author + "%"), + eb("lyricist", "like", "%" + query.author + "%"), + ])) } const res = await q.execute(); - return res; + return res.map(({ pieceId }) => pieceId); }, { query: t.Object({ - id: t.Optional(tbranded()), + name: t.Optional(t.String()), + author: t.Optional(t.String()), + offset: t.Optional(t.Integer({ minimum: 0 })), + limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), + }), + }) + + .get("/piece/:pieceId", async ({ db, query, params: { pieceId }, user }) => { + + if (user === null) { + return error("Unauthorized"); + } + + const piece = await db + .selectFrom("Piece") + .selectAll() + .where("pieceId", "=", pieceId) + .executeTakeFirst(); + + if (piece === undefined) { + return error("Not Found"); + } + + const attachments = await db + .selectFrom("Attachment") + .selectAll() + .where("pieceId", "=", pieceId) + .execute(); + + return { + ...piece, + attachments: attachments.map(({ sha256, ...rest }) => ({ + sha256: Buffer.from(sha256).toString("hex"), + ...rest, + })), + } + }, { + params: t.Object({ + pieceId: tbranded(), }), }) @@ -277,7 +353,10 @@ const app = new Elysia() .returningAll() .executeTakeFirstOrThrow(); - return res; + return { + ...res, + sha256: Buffer.from(res.sha256).toString("hex"), + }; }, { body: t.Object({ filename: t.String({ minLength: 1 }), @@ -289,26 +368,6 @@ const app = new Elysia() }), }) - .get("piece/:pieceId/attachment", async ({ db, params: { pieceId }, user }) => { - - if (user === null) { - return error("Unauthorized"); - } - - const res = await db - .selectFrom("Attachment") - .selectAll() - .where("pieceId", "=", pieceId) - .orderBy("filename") - .execute(); - - return res; - }, { - params: t.Object({ - pieceId: tbranded(), - }), - }) - /* NOTE The piece ID is reduntant, because attachment IDs are unique for the * entire DB, not just per piece. However, we consider a piece to be the * sole owner of an attachment, i.e. attachments are not shared (attachments @@ -316,7 +375,7 @@ const app = new Elysia() * reflect the ownership in the URLs. */ - .get("piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user }) => { + .get("piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => { if (user === null) { return error("Unauthorized"); @@ -336,11 +395,8 @@ const app = new Elysia() return error("Not Found"); } - return form({ - filename: res.filename, - mediaType: res.mediaType, - data: new File([res.data], res.filename, { type: res.mediaType }), - }); + set.headers["content-disposition"] = `attachment; filename="${res.filename}"`; + return Bun.file(res.data, { type: res.mediaType }); }, { params: t.Object({ pieceId: tbranded(), diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 1c28999..771f47a 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-slot": "catalog:", "clsx": "catalog:", "common": "workspace:^", + "effect": "catalog:", "lucide-react": "catalog:", "opensheetmusicdisplay": "catalog:", "react": "catalog:", diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts new file mode 100644 index 0000000..ef6ffef --- /dev/null +++ b/packages/frontend/src/cache.ts @@ -0,0 +1,111 @@ +import type * as Db from "backend/database"; +import { AttachmentId, PieceId, UserId } from "common"; +import { Cache, Duration, Effect, Option, pipe } from "effect"; +import { client, mapResponse } from "./client"; + +export interface User { + readonly userId: UserId; + readonly username: string; + readonly admin: boolean; +} + +export interface SystemInformation { + readonly createdBy: Option.Option; + readonly createdAt: string; + readonly modifiedBy: Option.Option; + readonly modifiedAt: Option.Option; +} + +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; + readonly lyricist: Option.Option; + readonly arranger: Option.Option; + readonly attachments: readonly Attachment[]; +} + +interface DbSystemInformation { + readonly createdBy: UserId | null; + readonly createdAt: string; + readonly modifiedBy: UserId | null; + readonly modifiedAt: string | null; +} + +export const denormalizeSystemInformation = ({ + 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, + ), + }, { concurrency: "unbounded" }), + Effect.map((si) => Object.freeze({ + ...rest, + ...si, + modifiedAt: Option.fromNullable(modifiedAt), + })), +); + +export const denormalizePiece = ({ + composer, + lyricist, + arranger, + attachments, + ...rest +}: Db.Piece & { attachments: (Omit & { sha256: string })[] }) => 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), +); + +export const userLookup = (userId: UserId) => pipe( + Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })), + Effect.flatMap(mapResponse), +); + +export const pieceLookup = (pieceId: PieceId) => pipe( + Effect.promise((signal) => client.piece({ pieceId }).get({ fetch: { signal } })), + Effect.flatMap(mapResponse), + Effect.flatMap(denormalizePiece), +); + +export const userCache = Effect.runSync(Cache.make({ + capacity: Infinity, + timeToLive: Duration.infinity, + lookup: userLookup, +})); + +export const pieceCache = Effect.runSync(Cache.make({ + capacity: Infinity, + timeToLive: Infinity, + lookup: pieceLookup, +})); diff --git a/packages/frontend/src/client.ts b/packages/frontend/src/client.ts index 446cb16..325e762 100644 --- a/packages/frontend/src/client.ts +++ b/packages/frontend/src/client.ts @@ -1,4 +1,9 @@ -import { treaty } from "@elysiajs/eden"; +import { Treaty, treaty } from "@elysiajs/eden"; import type { App } from "backend/app"; +import { Effect } from "effect"; + +export type ResponseEffect> = Effect.Effect extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude]>; export const client = treaty("localhost:3000", { fetch: { credentials: "include" } }); + +export const mapResponse = >({ error, data }: Treaty.TreatyResponse): ResponseEffect => error !== null ? Effect.fail(error) as any : Effect.succeed(data); diff --git a/packages/frontend/src/components/ui/dropdown-menu.tsx b/packages/frontend/src/components/ui/dropdown-menu.tsx index 9b93847..0c098b2 100644 --- a/packages/frontend/src/components/ui/dropdown-menu.tsx +++ b/packages/frontend/src/components/ui/dropdown-menu.tsx @@ -92,7 +92,7 @@ const DropdownMenuCheckboxItem = forwardRef< "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-stone-100 focus:text-stone-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-stone-800 dark:focus:text-stone-50", className )} - checked={checked} + {...(checked !== undefined) ? { checked } : undefined} {...props} > diff --git a/packages/frontend/src/hooks/useLoading.ts b/packages/frontend/src/hooks/useLoading.ts index b6d9e99..73244da 100644 --- a/packages/frontend/src/hooks/useLoading.ts +++ b/packages/frontend/src/hooks/useLoading.ts @@ -1,11 +1,9 @@ import { mapProp, Updater, useStore } from "@/hooks/useStore"; import { Treaty } from "@elysiajs/eden"; -import { ELYSIA_FORM_DATA } from "elysia"; +import { Effect, Fiber, pipe } from "effect"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -type FormDataFix = T extends { [ELYSIA_FORM_DATA]: infer U } ? U : T; - export type LoadingResult> = | { isLoading: true, @@ -13,9 +11,9 @@ export type LoadingResult> = error: null, } | { isLoading: false, - data: FormDataFix, + data: R[200], error: null, - setData: Updater>, + setData: Updater, } | { isLoading: false, data: null, @@ -25,7 +23,7 @@ export type LoadingResult> = } ; -export function useLoading>(fn: () => Promise>) { +export function useLoading>(fn: () => Promise>, deps: React.DependencyList) { const navigate = useNavigate(); @@ -56,6 +54,69 @@ export function useLoading>(fn: () => Promise< }); return () => { cancelled = true; }; + }, deps); + + return result; +} + +const IS_LOADING = Object.freeze({ + isLoading: true, + data: null, + error: null, + setData: null, +}); + +export type LoadingEffectResult = + | { + readonly isLoading: true, + readonly data: null, + readonly error: null, + readonly setData: null, + } | { + readonly isLoading: false, + readonly data: A, + readonly error: null, + readonly setData: Updater, + } | { + readonly isLoading: false, + readonly data: null, + readonly error: E, + readonly setData: null, + } + ; + +function mapSuccess(data: A) { + return Object.freeze({ isLoading: false as const, data, error: null }); +} + +function mapFailure(error: E) { + return Object.freeze({ isLoading: false as const, data: null, error }); +} + +export function useLoadingEffect(effect: Effect.Effect) { + + const [result, setResult] = useState>(IS_LOADING); + + useEffect(() => { + const fiber = pipe( + effect, + Effect.match({ + onSuccess: mapSuccess, + onFailure: mapFailure, + }), + Effect.tap((result) => { + setResult({ + ...result, + setData: ((action: any) => (setResult as any)(mapProp("data", action))) as any, + }); + }), + Effect.runFork, + ); + const interruptEffect = Fiber.interrupt(fiber); + + return () => { + Effect.runFork(interruptEffect); + }; }, []); return result; diff --git a/packages/frontend/src/routes/Attachment.tsx b/packages/frontend/src/routes/Attachment.tsx index 628b036..043bb01 100644 --- a/packages/frontend/src/routes/Attachment.tsx +++ b/packages/frontend/src/routes/Attachment.tsx @@ -11,14 +11,14 @@ export default function Attachment() { const pieceId = PieceId(params.pieceId!); const attachmentId = AttachmentId(params.attachmentId!); - const { isLoading, error, data } = useLoading(() => client.piece({ pieceId }).attachment({ attachmentId }).get()); + const { isLoading, error, data } = useLoading(() => client.piece({ pieceId }).attachment({ attachmentId }).get(), [pieceId, attachmentId]); const containerRef = useRef(null); useEffect(() => { if (isLoading || error !== null) return; - const url = URL.createObjectURL(data?.data); + const url = URL.createObjectURL(data); const render = () => osmd.render(); diff --git a/packages/frontend/src/routes/Home.tsx b/packages/frontend/src/routes/Home.tsx index 3451739..f18569f 100644 --- a/packages/frontend/src/routes/Home.tsx +++ b/packages/frontend/src/routes/Home.tsx @@ -1,17 +1,28 @@ +import { pieceCache, User } from "@/cache"; import { client } from "@/client"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { useLoading } from "@/hooks/useLoading"; +import { useLoading, useLoadingEffect } from "@/hooks/useLoading"; +import { PieceId } from "common"; +import { Cause, Effect, Option } from "effect"; import { Loader2, Plus } from "lucide-react"; import { FormEventHandler, ReactNode, useId, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; export function Home() { - const { isLoading, error, data } = useLoading(() => client.piece.get({ query: {} })); + const [name, setName] = useState(""); + const [author, setAuthor] = useState(""); + + const { isLoading, error, data: pieceIds } = useLoading(() => client.piece.get({ + query: { + ...(name !== "" ? { name } : undefined), + ...(author !== "" ? { author } : undefined), + }, + }), [name, author]); if (isLoading) { return ( @@ -24,16 +35,32 @@ export function Home() { return (
{error !== null ? ( - `Wystąpił błąd: ${error.value}` + Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}` ) : (<> - - - - - - +
+ + + + + + + setName(e.target.value)} + /> + setAuthor(e.target.value)} + /> +
@@ -44,29 +71,7 @@ export function Home() { - {data.map((piece) => { - const composerParts: ReactNode[] = []; - if (piece.composer !== null) composerParts.push(piece.composer); - if (piece.arranger !== null) composerParts.push(`opracowanie: ${piece.arranger}`); - if (piece.lyricist !== null) composerParts.push(`słowa: ${piece.lyricist}`); - if (composerParts.length === 0) composerParts.push(Nieznani); - return ( - - {piece.name} - {...composerParts.flatMap((x, i, a) => i < a.length - 1 ? [x,
] : [x])}
- - {piece.createdAt} - {piece.createdBy !== null && <>
przez {piece.createdBy}} -
- - {piece.modifiedAt === null && piece.modifiedBy === null ? "\u2014" - : piece.modifiedAt !== null && piece.modifiedBy === null ? piece.modifiedAt - : piece.modifiedAt === null ? `przez ${piece.createdBy}` - : <>{piece.createdAt}
przez {piece.createdBy}} -
-
- ); - })} + {pieceIds.map((pieceId) => )}
)} @@ -74,6 +79,61 @@ export function Home() { ); } +namespace PieceRow { + export interface Props { + readonly pieceId: PieceId; + } +} + +function PieceRow(props: PieceRow.Props) { + + const { isLoading, error, data: piece } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(props.pieceId))); + + if (isLoading) { + return ( + + Ładowanie… + + ); + } + + if (error !== null) { + return ( + + Wystąpił błąd: {error.value} + + ); + } + + const composerParts: ReactNode[] = []; + + if (Option.isSome(piece.composer)) composerParts.push(piece.composer.value); + if (Option.isSome(piece.arranger)) composerParts.push(`opracowanie: ${piece.arranger.value}`); + if (Option.isSome(piece.lyricist)) composerParts.push(`słowa: ${piece.lyricist.value}`); + if (composerParts.length === 0) composerParts.push(Nieznani); + + return ( + + + {piece.name} + + + {...composerParts.flatMap((x, i, a) => i < a.length - 1 ? [x,
] : [x])} +
+ + {piece.createdAt} + {Option.isSome(piece.createdBy) && <>
przez {piece.createdBy.value.username}} +
+ + {Option.isNone(piece.modifiedAt) && Option.isNone(piece.modifiedBy) ? "\u2014" + : Option.isSome(piece.modifiedAt) && Option.isNone(piece.modifiedBy) ? piece.modifiedAt.value + : Option.isNone(piece.modifiedAt) ? `przez ${(piece.modifiedBy as Option.Some).value.username}` + : <>{piece.modifiedAt.value}
przez {(piece.modifiedBy as Option.Some).value.username}} +
+
+ ); +} + function AddPieceDialogContent() { const navigate = useNavigate(); diff --git a/packages/frontend/src/routes/Login.tsx b/packages/frontend/src/routes/Login.tsx index 287bf59..2508ff6 100644 --- a/packages/frontend/src/routes/Login.tsx +++ b/packages/frontend/src/routes/Login.tsx @@ -36,7 +36,7 @@ export function Login() { password: loginPassword, }); - if (error) { + if (error !== null) { console.error(error.value); return; } diff --git a/packages/frontend/src/routes/Piece.tsx b/packages/frontend/src/routes/Piece.tsx index 97ae063..9f6fd20 100644 --- a/packages/frontend/src/routes/Piece.tsx +++ b/packages/frontend/src/routes/Piece.tsx @@ -1,16 +1,17 @@ +import { Attachment, denormalizeSystemInformation, type Piece, pieceCache, User } from "@/cache"; import { client } from "@/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { useLoading } from "@/hooks/useLoading"; -import { Updater } from "@/hooks/useStore"; +import { useLoadingEffect } from "@/hooks/useLoading"; +import { mapProp, Update, Updater } from "@/hooks/useStore"; import { timeout } from "@/lib/utils"; import { Label } from "@radix-ui/react-label"; -import type { Attachment, Piece } from "backend/database"; import clsx from "clsx"; import { PieceId } from "common"; import { getMediaTypeForFilename } from "common/MediaType"; -import { ELYSIA_FORM_DATA } from "elysia"; +import { Cause, Effect, Option } from "effect"; +import { constant } from "effect/Function"; import { Download, Loader2, Trash, UploadCloud } from "lucide-react"; import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react"; import { Link, useParams } from "react-router-dom"; @@ -19,10 +20,14 @@ export function Piece() { const id = PieceId(useParams().pieceId!); - const piece = useLoading(() => client.piece.get({ query: { id } })); - const attachments = useLoading(() => client.piece({ pieceId: id }).attachment.get()); + const { isLoading, error, data, setData } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(id))); - if (piece.isLoading || attachments.isLoading) { + const setAttachments = useCallback((action: Update) => { + setData!(mapProp("attachments", action)); + Effect.runFork(pieceCache.invalidate(id)); + }, [setData]); + + if (isLoading) { return (
Ładowanie…
@@ -32,18 +37,16 @@ export function Piece() { return (
- {piece.error !== null || attachments.error !== null ? ( - `Wystąpił błąd: ${[piece.error?.value, attachments.error?.value].filter(Boolean).join(", ")}` - ) : piece.data[0] === undefined ? ( - "Utwór nie istnieje" + {error !== null ? ( + Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}` ) : (<>

Utwór

- +

Załączniki

- +
- + )}
); @@ -58,9 +61,9 @@ namespace PieceForm { function PieceForm(props: PieceForm.Props) { const [name, setName] = useState(props.piece.name); - const [composer, setComposer] = useState(props.piece.composer ?? ""); - const [lyricist, setLyricist] = useState(props.piece.lyricist ?? ""); - const [arranger, setArranger] = useState(props.piece.arranger ?? ""); + const [composer, setComposer] = useState(() => Option.getOrElse(props.piece.composer, constant(""))); + const [lyricist, setLyricist] = useState(() => Option.getOrElse(props.piece.lyricist, constant(""))); + const [arranger, setArranger] = useState(() => Option.getOrElse(props.piece.arranger, constant(""))); const nameId = useId(); const composerId = useId(); @@ -83,7 +86,7 @@ function PieceForm(props: PieceForm.Props) { arranger: arranger.length > 0 ? arranger : null, }); - if (error) { + if (error !== null) { console.error(error.value); return; } @@ -144,7 +147,7 @@ namespace Attachments { export interface Props { readonly pieceId: PieceId; readonly attachments: readonly Attachment[]; - readonly setAttachments: Updater; + readonly setAttachments: Updater; } } @@ -178,28 +181,27 @@ function Attachments(props: Attachments.Props) { namespace AttachmentRow { export interface Props { readonly attachment: Attachment; - readonly setAttachments: Updater; + readonly setAttachments: Updater; } } function AttachmentRow(props: AttachmentRow.Props) { const download = useCallback(async () => { - const { error, data: _data } = await client + const { error, data } = await client .piece({ pieceId: props.attachment.pieceId }) .attachment({ attachmentId: props.attachment.attachmentId }) - .get(); + .get() if (error !== null) { console.error(error.value); return; } - const data = _data as unknown as typeof _data[ELYSIA_FORM_DATA]; - const url = URL.createObjectURL(data.data); + const url = URL.createObjectURL(data); const a = document.createElement("a"); a.href = url; - a.download = data.filename; + a.download = data.name; a.click(); URL.revokeObjectURL(url); }, [props.attachment.attachmentId, props.attachment.pieceId]); @@ -211,7 +213,7 @@ function AttachmentRow(props: AttachmentRow.Props) { event.preventDefault(); - const { error, data: _data } = await client + const { error, data } = await client .piece({ pieceId: props.attachment.pieceId }) .attachment({ attachmentId: props.attachment.attachmentId }) .get(); @@ -221,8 +223,7 @@ function AttachmentRow(props: AttachmentRow.Props) { return; } - const data = _data as unknown as typeof _data[ELYSIA_FORM_DATA]; - const url = URL.createObjectURL(data.data); + const url = URL.createObjectURL(data); window.open(url, "_target"); URL.revokeObjectURL(url); }, [props.attachment.mediaType, props.attachment.attachmentId, props.attachment.pieceId]); @@ -256,16 +257,18 @@ function AttachmentRow(props: AttachmentRow.Props) { props.attachment.filename )} - {props.attachment.mediaType} - - {props.attachment.createdAt} - {props.attachment.createdBy !== null && <>
przez {props.attachment.createdBy}} + + {props.attachment.mediaType} - - {props.attachment.modifiedAt === null && props.attachment.modifiedBy === null ? "\u2014" - : props.attachment.modifiedAt !== null && props.attachment.modifiedBy === null ? props.attachment.modifiedAt - : props.attachment.modifiedAt === null ? `przez ${props.attachment.createdBy}` - : <>{props.attachment.createdAt}
przez {props.attachment.createdBy}} + + {props.attachment.createdAt} + {Option.isSome(props.attachment.createdBy) && <>
przez {props.attachment.createdBy.value.username}} +
+ + {Option.isNone(props.attachment.modifiedAt) && Option.isNone(props.attachment.modifiedBy) ? "\u2014" + : Option.isSome(props.attachment.modifiedAt) && Option.isNone(props.attachment.modifiedBy) ? props.attachment.modifiedAt.value + : Option.isNone(props.attachment.modifiedAt) ? `przez ${(props.attachment.modifiedBy as Option.Some).value.username}` + : <>{props.attachment.modifiedAt.value}
przez {(props.attachment.modifiedBy as Option.Some).value.username}}