Explicit, optional OPFS cache for attachments
This commit is contained in:
97
packages/frontend/src/fscache.ts
Normal file
97
packages/frontend/src/fscache.ts
Normal file
@@ -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<FileSystemDirectoryHandle, UnknownException>,
|
||||||
|
(handle: FileSystemDirectoryHandle, name: string, options?: FileSystemGetDirectoryOptions) => Effect.Effect<FileSystemDirectoryHandle, UnknownException>
|
||||||
|
>(
|
||||||
|
(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<FileSystemFileHandle, UnknownException>,
|
||||||
|
(handle: FileSystemDirectoryHandle, name: string, options?: FileSystemGetFileOptions) => Effect.Effect<FileSystemFileHandle, UnknownException>
|
||||||
|
>(
|
||||||
|
(args) => args[0].kind === "directory",
|
||||||
|
(handle, name, options) => Effect.tryPromise(() => handle.getFileHandle(name, options)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createWritable = dual<
|
||||||
|
(options?: FileSystemCreateWritableOptions) => (handle: FileSystemFileHandle) => Effect.Effect<FileSystemWritableFileStream, UnknownException>,
|
||||||
|
(handle: FileSystemFileHandle, options?: FileSystemCreateWritableOptions) => Effect.Effect<FileSystemWritableFileStream, UnknownException>
|
||||||
|
>(
|
||||||
|
(args) => args[0].kind === "file",
|
||||||
|
(handle, options) => Effect.tryPromise(() => handle.createWritable(options)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const removeEntry = dual<
|
||||||
|
(name: string, options?: FileSystemRemoveOptions) => (handle: FileSystemDirectoryHandle) => Effect.Effect<void, UnknownException>,
|
||||||
|
(handle: FileSystemDirectoryHandle, name: string, options?: FileSystemRemoveOptions) => Effect.Effect<void, UnknownException>
|
||||||
|
>(
|
||||||
|
(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<ArrayBuffer>, 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());
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
import { client } from "@/client";
|
import { client } from "@/client";
|
||||||
|
import * as FSCache from "@/fscache";
|
||||||
import { useLoading } from "@/hooks/useLoading.ts";
|
import { useLoading } from "@/hooks/useLoading.ts";
|
||||||
import { AttachmentId } from "common";
|
import { AttachmentId } from "common";
|
||||||
import { Match } from "effect";
|
import { Effect, Match, Option } from "effect";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
|
import { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
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() {
|
export default function Attachment() {
|
||||||
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const attachmentId = AttachmentId.make(params.attachmentId!);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +59,7 @@ export default function Attachment() {
|
|||||||
if (data.mediaType === "application/vnd.recordare.musicxml" || data.mediaType === "application/vnd.recordare.musicxml+xml") {
|
if (data.mediaType === "application/vnd.recordare.musicxml" || data.mediaType === "application/vnd.recordare.musicxml+xml") {
|
||||||
return <MusicXmlAttachment data={data.data} mediaType={data.mediaType} />;
|
return <MusicXmlAttachment data={data.data} mediaType={data.mediaType} />;
|
||||||
} else if (data.mediaType === "application/pdf") {
|
} else if (data.mediaType === "application/pdf") {
|
||||||
return <PdfAttachment data={data.data} filename={data.filename} mediaType={data.mediaType} />;
|
return <PdfAttachment data={data.data} mediaType={data.mediaType} />;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -141,21 +159,19 @@ function MusicXmlAttachment({ data, mediaType }: MusicXmlAttachment.Props) {
|
|||||||
namespace PdfAttachment {
|
namespace PdfAttachment {
|
||||||
export interface Props {
|
export interface Props {
|
||||||
readonly data: Uint8Array<ArrayBuffer>;
|
readonly data: Uint8Array<ArrayBuffer>;
|
||||||
readonly filename: string;
|
|
||||||
readonly mediaType: "application/pdf";
|
readonly mediaType: "application/pdf";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function PdfAttachment({
|
function PdfAttachment({
|
||||||
data,
|
data,
|
||||||
filename,
|
|
||||||
mediaType,
|
mediaType,
|
||||||
}: PdfAttachment.Props) {
|
}: PdfAttachment.Props) {
|
||||||
|
|
||||||
const [url, setUrl] = useState<string>("");
|
const [url, setUrl] = useState<string>("");
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const file = new File([data], filename, { type: mediaType });
|
const file = new Blob([data], { type: mediaType });
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
setUrl(url);
|
setUrl(url);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { Attachment, denormalizePiece, denormalizeSystemInformation, pieceCache,
|
|||||||
import { client } from "@/client";
|
import { client } from "@/client";
|
||||||
import { PieceDialogContent } from "@/components/PieceDialog";
|
import { PieceDialogContent } from "@/components/PieceDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import * as FSCache from "@/fscache";
|
||||||
import { useCache } from "@/hooks/useCache";
|
import { useCache } from "@/hooks/useCache";
|
||||||
import { mapProp } from "@/hooks/useStore";
|
import { mapProp } from "@/hooks/useStore";
|
||||||
import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets";
|
import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets";
|
||||||
@@ -14,10 +15,10 @@ import clsx from "clsx";
|
|||||||
import { PieceId } from "common";
|
import { PieceId } from "common";
|
||||||
import * as Body from "common/Body";
|
import * as Body from "common/Body";
|
||||||
import { getMediaTypeForFilename } from "common/MediaType";
|
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 { constant } from "effect/Function";
|
||||||
import { Download, EllipsisVertical, Loader2, Trash, UploadCloud } from "lucide-react";
|
import { CloudAlert, CloudCheck, CloudDownload, Download, EllipsisVertical, Loader2, Trash, UploadCloud } from "lucide-react";
|
||||||
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
|
import { DragEventHandler, FormEventHandler, useCallback, useId, useEffect as useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
export function Piece() {
|
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) {
|
function AttachmentRow(props: AttachmentRow.Props) {
|
||||||
|
|
||||||
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
|
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 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, {
|
const file = new File([data], props.attachment.filename, {
|
||||||
type: mediaType,
|
type: props.attachment.mediaType,
|
||||||
lastModified: pipe(
|
lastModified: pipe(
|
||||||
props.attachment.modifiedAt,
|
props.attachment.modifiedAt,
|
||||||
Option.getOrElse(() => props.attachment.createdAt),
|
Option.getOrElse(() => props.attachment.createdAt),
|
||||||
@@ -228,11 +257,24 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
|||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = filename;
|
a.download = props.attachment.filename;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}).pipe(Effect.runPromise);
|
}).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* () {
|
const doDelete = () => Effect.gen(function* () {
|
||||||
yield* client.deleteAttachment(props.attachment.attachmentId);
|
yield* client.deleteAttachment(props.attachment.attachmentId);
|
||||||
yield* pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter<Attachment>((a) => a.attachmentId !== props.attachment.attachmentId)));
|
yield* pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter<Attachment>((a) => a.attachmentId !== props.attachment.attachmentId)));
|
||||||
@@ -259,13 +301,18 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
|||||||
setCloneDialogOpen(false);
|
setCloneDialogOpen(false);
|
||||||
}).pipe(Effect.runPromise);
|
}).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 (<>
|
return (<>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{props.attachment.mediaType === "application/vnd.recordare.musicxml"
|
{props.attachment.mediaType === "application/vnd.recordare.musicxml"
|
||||||
|| props.attachment.mediaType === "application/vnd.recordare.musicxml+xml"
|
|| props.attachment.mediaType === "application/vnd.recordare.musicxml+xml"
|
||||||
|| props.attachment.mediaType === "application/pdf" ? (
|
|| props.attachment.mediaType === "application/pdf" ? (
|
||||||
<Link className="underline" to={`attachment/${props.attachment.attachmentId}`}>
|
<Link className="underline" to={`attachment/${props.attachment.attachmentId}?${searchParams}`}>
|
||||||
{props.attachment.filename}
|
{props.attachment.filename}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -281,13 +328,23 @@ function AttachmentRow(props: AttachmentRow.Props) {
|
|||||||
<TableCell className="text-center text-xs">
|
<TableCell className="text-center text-xs">
|
||||||
{modified(props.attachment)}
|
{modified(props.attachment)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center flex justify-center gap-4">
|
<TableCell className="text-center flex justify-center gap-1">
|
||||||
<Button type="button" variant="ghost" size="icon" title="Pobierz" onClick={download}>
|
<Button type="button" variant="ghost" size="icon" title="Pobierz" onClick={download}>
|
||||||
<Download />
|
<Download />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={doDelete}>
|
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={doDelete}>
|
||||||
<Trash />
|
<Trash />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" title="Zapisz" onClick={localCopyState === LocalCopyState.Online ? downloadAndSave : undefined}>
|
||||||
|
{Match.value(localCopyState).pipe(
|
||||||
|
Match.when(LocalCopyState.Uninitialized, () => <Loader2 className="animate-spin" />),
|
||||||
|
Match.when(LocalCopyState.Online, () => <CloudDownload />),
|
||||||
|
Match.when(LocalCopyState.Loading, () => <Loader2 className="animate-spin" />),
|
||||||
|
Match.when(LocalCopyState.Offline, () => <CloudCheck />),
|
||||||
|
Match.when(LocalCopyState.Error, () => <CloudAlert />),
|
||||||
|
Match.exhaustive,
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant="ghost" size="icon" title="Więcej">
|
<Button type="button" variant="ghost" size="icon" title="Więcej">
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ function EntryRow({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{authors(piece)}
|
{authors(piece)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center flex justify-center gap-4">
|
<TableCell className="text-center flex justify-center gap-1">
|
||||||
<Button type="button" variant="ghost" size="icon" title="Przesuń w górę" onClick={moveUp} disabled={no === 1}>
|
<Button type="button" variant="ghost" size="icon" title="Przesuń w górę" onClick={moveUp} disabled={no === 1}>
|
||||||
<ChevronUp />
|
<ChevronUp />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
|
<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 className="flex flex-col gap-4 p-4 border rounded">
|
||||||
|
<h3 className="font-bold text-lg">Załączniki offline</h3>
|
||||||
|
{!isLoading && error === null && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>W użyciu:</div>
|
||||||
|
<div>{data.stats.usage !== undefined ? bytes(data.stats.usage) : "b/d"}</div>
|
||||||
|
<div>Limit:</div>
|
||||||
|
<div>{data.stats.quota !== undefined ? bytes(data.stats.quota) : "b/d"}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button variant="destructive" onClick={cleanup} disabled={isCleanupPending}>
|
||||||
|
{isCleanupPending && <Loader2 className="animate-spin" />}
|
||||||
|
<Trash />Wyczyść
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Piece, DenormalizedSystemInformation } from "@/cache";
|
import { DenormalizedSystemInformation, Piece } from "@/cache";
|
||||||
import { timeout } from "@/lib/utils";
|
import { timeout } from "@/lib/utils";
|
||||||
import { Clock, DateTime, Duration, Option } from "effect";
|
import { Clock, DateTime, Duration, Option } from "effect";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
@@ -74,3 +74,28 @@ export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformati
|
|||||||
|
|
||||||
return nodes;
|
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";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user