diff --git a/packages/frontend/src/fscache.ts b/packages/frontend/src/fscache.ts new file mode 100644 index 0000000..b5195a2 --- /dev/null +++ b/packages/frontend/src/fscache.ts @@ -0,0 +1,97 @@ +import { Sha256 } from "common"; +import * as Body from "common/Body"; +import { Effect, Encoding, pipe } from "effect"; +import { UnknownException } from "effect/Cause"; +import { dual } from "effect/Function"; + +export const getDirectoryHandle = dual< + (name: string, options?: FileSystemGetDirectoryOptions) => (handle: FileSystemDirectoryHandle) => Effect.Effect, + (handle: FileSystemDirectoryHandle, name: string, options?: FileSystemGetDirectoryOptions) => Effect.Effect +>( + (args) => args[0].kind === "directory", + (handle, name, options) => Effect.tryPromise(() => handle.getDirectoryHandle(name, options)), +); + +export const getFileHandle = dual< + (name: string, options?: FileSystemGetFileOptions) => (handle: FileSystemDirectoryHandle) => Effect.Effect, + (handle: FileSystemDirectoryHandle, name: string, options?: FileSystemGetFileOptions) => Effect.Effect +>( + (args) => args[0].kind === "directory", + (handle, name, options) => Effect.tryPromise(() => handle.getFileHandle(name, options)), +); + +export const createWritable = dual< + (options?: FileSystemCreateWritableOptions) => (handle: FileSystemFileHandle) => Effect.Effect, + (handle: FileSystemFileHandle, options?: FileSystemCreateWritableOptions) => Effect.Effect +>( + (args) => args[0].kind === "file", + (handle, options) => Effect.tryPromise(() => handle.createWritable(options)), +); + +export const removeEntry = dual< + (name: string, options?: FileSystemRemoveOptions) => (handle: FileSystemDirectoryHandle) => Effect.Effect, + (handle: FileSystemDirectoryHandle, name: string, options?: FileSystemRemoveOptions) => Effect.Effect +>( + (args) => args[0].kind === "directory", + (handle, name, options) => Effect.tryPromise(() => handle.removeEntry(name, options)), +); + +export const getOpfsRoot = Effect.tryPromise(() => navigator.storage.getDirectory()); + +export const getFilesDir = pipe( + getOpfsRoot, + Effect.flatMap(getDirectoryHandle("files", { create: true })), +); + +export const getFile = (sha256: Sha256) => pipe( + sha256, + Encoding.encodeBase64Url, + getFile_base64url, +); + +export const getFile_base64url = (sha256_base64url: string) => Effect.gen(function* () { + + const filesDir = yield* getFilesDir; + const fileHandle = yield* getFileHandle(filesDir, sha256_base64url, { create: false }); + const file = yield* Effect.tryPromise(() => fileHandle.getFile()); + + const buffer = yield* Body.arrayBuffer(file); + const data = new Uint8Array(buffer); + + return data; + +}).pipe(Effect.option); + +export const storeFile = (data: Uint8Array, sha256: Sha256) => Effect.gen(function* () { + + const sha256_base64url = Encoding.encodeBase64Url(sha256); + + const filesDir = yield* getFilesDir; + const file = yield* getFileHandle(filesDir, sha256_base64url, { create: true }); + const writable = yield* createWritable(file, { keepExistingData: false }); + + yield* Effect.tryPromise(() => writable.write(data)); + yield* Effect.tryPromise(() => writable.close()); +}); + +export const hasFile = (sha256: Sha256) => Effect.gen(function* () { + + const sha256_base64url = Encoding.encodeBase64Url(sha256); + + const filesDir = yield* getFilesDir; + yield* getFileHandle(filesDir, sha256_base64url, { create: false }); + + return true; + +}).pipe(Effect.catchAll(() => Effect.succeed(false))); + +export const removeFile = (sha256: Sha256) => Effect.gen(function* () { + + const sha256_base64url = Encoding.encodeBase64Url(sha256); + + const filesDir = yield* getFilesDir; + yield* removeEntry(filesDir, sha256_base64url, { recursive: false }); + +}).pipe(Effect.ignore); + +export const stats = Effect.tryPromise(() => navigator.storage.estimate()); diff --git a/packages/frontend/src/routes/Attachment.tsx b/packages/frontend/src/routes/Attachment.tsx index d7502ff..abc0941 100644 --- a/packages/frontend/src/routes/Attachment.tsx +++ b/packages/frontend/src/routes/Attachment.tsx @@ -1,18 +1,36 @@ import { client } from "@/client"; +import * as FSCache from "@/fscache"; import { useLoading } from "@/hooks/useLoading.ts"; import { AttachmentId } from "common"; -import { Match } from "effect"; +import { Effect, Match, Option } from "effect"; import JSZip from "jszip"; import { OpenSheetMusicDisplay } from "opensheetmusicdisplay"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; export default function Attachment() { const params = useParams(); + const [searchParams] = useSearchParams(); const attachmentId = AttachmentId.make(params.attachmentId!); - const { isLoading, error, data } = useLoading(client.getAttachment(attachmentId), [attachmentId]); + const { isLoading, error, data } = useLoading(Effect.gen(function* () { + + const sha256_base64url = searchParams.get("sha256"); + const mediaType = searchParams.get("mediaType"); + + if (sha256_base64url !== null && mediaType !== null) { + + const data = yield* FSCache.getFile_base64url(sha256_base64url); + + if (Option.isSome(data)) { + return { data: data.value, mediaType }; + } + } + + const res = yield* client.getAttachment(attachmentId); + return { data: res.data, mediaType: res.mediaType }; + }), [attachmentId, searchParams]); if (isLoading) { return ( @@ -41,7 +59,7 @@ export default function Attachment() { if (data.mediaType === "application/vnd.recordare.musicxml" || data.mediaType === "application/vnd.recordare.musicxml+xml") { return ; } else if (data.mediaType === "application/pdf") { - return ; + return ; } else { return null; } @@ -141,21 +159,19 @@ function MusicXmlAttachment({ data, mediaType }: MusicXmlAttachment.Props) { namespace PdfAttachment { export interface Props { readonly data: Uint8Array; - readonly filename: string; readonly mediaType: "application/pdf"; } } function PdfAttachment({ data, - filename, mediaType, }: PdfAttachment.Props) { const [url, setUrl] = useState(""); useLayoutEffect(() => { - const file = new File([data], filename, { type: mediaType }); + const file = new Blob([data], { type: mediaType }); const url = URL.createObjectURL(file); setUrl(url); diff --git a/packages/frontend/src/routes/Piece.tsx b/packages/frontend/src/routes/Piece.tsx index 27d5697..3ddc05d 100644 --- a/packages/frontend/src/routes/Piece.tsx +++ b/packages/frontend/src/routes/Piece.tsx @@ -2,10 +2,11 @@ import { Attachment, denormalizePiece, denormalizeSystemInformation, pieceCache, import { client } from "@/client"; import { PieceDialogContent } from "@/components/PieceDialog"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { Dialog } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import * as FSCache from "@/fscache"; import { useCache } from "@/hooks/useCache"; import { mapProp } from "@/hooks/useStore"; import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets"; @@ -14,10 +15,10 @@ import clsx from "clsx"; import { PieceId } from "common"; import * as Body from "common/Body"; import { getMediaTypeForFilename } from "common/MediaType"; -import { Array, Cause, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect"; +import { Array, Cause, DateTime, Effect, Encoding, Exit, Fiber, Match, Option, pipe, Scope } from "effect"; import { constant } from "effect/Function"; -import { Download, EllipsisVertical, Loader2, Trash, UploadCloud } from "lucide-react"; -import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react"; +import { CloudAlert, CloudCheck, CloudDownload, Download, EllipsisVertical, Loader2, Trash, UploadCloud } from "lucide-react"; +import { DragEventHandler, FormEventHandler, useCallback, useId, useEffect as useLayoutEffect, useMemo, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; export function Piece() { @@ -209,15 +210,43 @@ namespace AttachmentRow { } } +enum LocalCopyState { + Uninitialized = "uninitialized", + Online = "online", + Offline = "offline", + Loading = "loading", + Error = "error", +} + function AttachmentRow(props: AttachmentRow.Props) { const [cloneDialogOpen, setCloneDialogOpen] = useState(false); + const [localCopyState, setLocalCopyState] = useState(LocalCopyState.Uninitialized); + + useLayoutEffect(() => { + if (localCopyState !== LocalCopyState.Uninitialized) return; + + setLocalCopyState(LocalCopyState.Loading); + + pipe( + FSCache.hasFile(props.attachment.sha256), + Effect.tap((exists) => setLocalCopyState(exists ? LocalCopyState.Offline : LocalCopyState.Online)), + Effect.runFork, + ); + }, [props.attachment.sha256, localCopyState]); const download = () => Effect.gen(function* () { - const { data, mediaType, filename } = yield* client.getAttachment(props.attachment.attachmentId); + const data = yield* pipe( + FSCache.getFile(props.attachment.sha256), + Effect.flatten, + Effect.catchTag("NoSuchElementException", () => pipe( + client.getAttachment(props.attachment.attachmentId), + Effect.map((res) => res.data), + )), + ); - const file = new File([data], filename, { - type: mediaType, + const file = new File([data], props.attachment.filename, { + type: props.attachment.mediaType, lastModified: pipe( props.attachment.modifiedAt, Option.getOrElse(() => props.attachment.createdAt), @@ -228,11 +257,24 @@ function AttachmentRow(props: AttachmentRow.Props) { const url = URL.createObjectURL(file); const a = document.createElement("a"); a.href = url; - a.download = filename; + a.download = props.attachment.filename; a.click(); URL.revokeObjectURL(url); }).pipe(Effect.runPromise); + const downloadAndSave = () => Effect.gen(function* () { + setLocalCopyState(LocalCopyState.Loading); + const { data } = yield* client.getAttachment(props.attachment.attachmentId); + const exit = yield* pipe( + FSCache.storeFile(data, props.attachment.sha256), + Effect.exit, + ); + Exit.match(exit, { + onFailure: () => setLocalCopyState(LocalCopyState.Error), + onSuccess: () => setLocalCopyState(LocalCopyState.Offline), + }); + }).pipe(Effect.runPromise); + const doDelete = () => Effect.gen(function* () { yield* client.deleteAttachment(props.attachment.attachmentId); yield* pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter((a) => a.attachmentId !== props.attachment.attachmentId))); @@ -259,13 +301,18 @@ function AttachmentRow(props: AttachmentRow.Props) { setCloneDialogOpen(false); }).pipe(Effect.runPromise); + const searchParams = useMemo(() => new URLSearchParams({ + sha256: Encoding.encodeBase64Url(props.attachment.sha256), + mediaType: props.attachment.mediaType, + }).toString(), [props.attachment.sha256, props.attachment.mediaType]); + return (<> {props.attachment.mediaType === "application/vnd.recordare.musicxml" || props.attachment.mediaType === "application/vnd.recordare.musicxml+xml" || props.attachment.mediaType === "application/pdf" ? ( - + {props.attachment.filename} ) : ( @@ -281,13 +328,23 @@ function AttachmentRow(props: AttachmentRow.Props) { {modified(props.attachment)} - + + diff --git a/packages/frontend/src/routes/Settings.tsx b/packages/frontend/src/routes/Settings.tsx index 1ddf855..5bb65af 100644 --- a/packages/frontend/src/routes/Settings.tsx +++ b/packages/frontend/src/routes/Settings.tsx @@ -1,7 +1,54 @@ +import { Button } from "@/components/ui/button"; +import * as FSCache from "@/fscache"; +import { useLoading } from "@/hooks/useLoading"; +import { bytes, SAVE_DELAY } from "@/snippets"; +import { Effect, Fiber, Scope } from "effect"; +import { Loader2, Trash } from "lucide-react"; +import { useState } from "react"; + export function Settings() { + + const { isLoading, error, data, refresh } = useLoading(Effect.gen(function* () { + const stats = yield* FSCache.stats; + + return { stats }; + }), []); + + const [isCleanupPending, setIsCleanupPending] = useState(false); + + const cleanup = () => Effect.scopedWith((scope) => Effect.gen(function* () { + + const delay = yield* Effect.fork(SAVE_DELAY); + + setIsCleanupPending(true); + yield* Scope.addFinalizer(scope, Effect.gen(function* () { + yield* Fiber.join(delay); + setIsCleanupPending(false); + })); + + const root = yield* FSCache.getOpfsRoot; + yield* FSCache.removeEntry(root, "files", { recursive: true }); + yield* refresh; + + })).pipe(Effect.runFork); + return (
- Jakby były ustawienia, to by tu były. +
+

Załączniki offline

+ {!isLoading && error === null && ( +
+
W użyciu:
+
{data.stats.usage !== undefined ? bytes(data.stats.usage) : "b/d"}
+
Limit:
+
{data.stats.quota !== undefined ? bytes(data.stats.quota) : "b/d"}
+
+ )} + +
); } diff --git a/packages/frontend/src/snippets.tsx b/packages/frontend/src/snippets.tsx index 13c0267..7dd7ffa 100644 --- a/packages/frontend/src/snippets.tsx +++ b/packages/frontend/src/snippets.tsx @@ -1,4 +1,4 @@ -import { Piece, DenormalizedSystemInformation } from "@/cache"; +import { DenormalizedSystemInformation, Piece } from "@/cache"; import { timeout } from "@/lib/utils"; import { Clock, DateTime, Duration, Option } from "effect"; import { ReactNode } from "react"; @@ -74,3 +74,28 @@ export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformati return nodes; } + +export function bytes(bytes: number): string { + + if (bytes < 1024) return bytes.toLocaleString("pl-PL", { + maximumFractionDigits: 0, + }) + " B"; + + const kib = bytes / 1024; + if (kib < 1024) return kib.toLocaleString("pl-PL", { + minimumSignificantDigits: 1, + maximumSignificantDigits: 4, + }) + " kiB"; + + const mib = kib / 1024; + if (mib < 1024) return mib.toLocaleString("pl-PL", { + minimumSignificantDigits: 1, + maximumSignificantDigits: 4, + }) + " MiB"; + + const gib = mib / 1024; + return gib.toLocaleString("pl-PL", { + minimumSignificantDigits: 1, + maximumSignificantDigits: 4, + }) + " GiB"; +}