Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/chrisvander/crate. The future of cloud storage.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291import { DbClient, DbProvider, eq, useDbClient, useLiveQuery } from "@tanstack/react-db"import { useCallback, useEffect, useRef, useState } from "preact/hooks"import { QueryClient } from "@tanstack/query-core"import { twJoin } from "tailwind-merge"import { createFileActions, filesCollection } from "../../lib/actions"import { usePreference } from "../../hooks/usePreference"import { selectedEntries, sortOptions, fileQuery, viewOptions } from "../../lib/files"import { type FileEntry } from "../../lib/api"import { FileList } from "./FileList"import { Toolbar } from "./Toolbar"import { NameDialog } from "./NameDialog"import { FileInspector } from "./FileInspector"import { PopoverMenu, makeOpt } from "./PopoverMenu"import type Anchor from "../../models/Anchor"import useClickOutside from "../../hooks/useClickOutside"
type NameAction = { kind: "file" | "directory" } | { kind: "rename"; file: FileEntry }
const contentUrl = (id: string) => `/files/${encodeURIComponent(id)}`const dbClient = new DbClient({ queryClient: new QueryClient() })
export function Explorer({ did, spaceUri }: { did: string; spaceUri: string }) { return ( <DbProvider client={dbClient}> <ExplorerBody did={did} spaceUri={spaceUri} /> </DbProvider> )}
function useFilesCollection() { return useDbClient().collection(filesCollection)}
export function ExplorerBody({ did, spaceUri }: { did: string; spaceUri: string }) { const [trail, setTrail] = useState<FileEntry[]>([]) const [selected, setSelected] = useState<string[]>([]) const [search, setSearch] = useState("") const [descending, setDescending] = useState(false) const [sort, setSort] = usePreference("crate.sort", sortOptions, "Name") const [view, setView] = usePreference("crate.view", viewOptions, "list") const [dialog, setDialog] = useState<NameAction | null>(null) const [inspecting, setInspecting] = useState(false) const [context, setContext] = useState<Anchor | null>(null) const filesRef = useRef<HTMLDivElement>(null) const parentId = trail.at(-1)?.id const [error, setError] = useState<Error | null>(null) const collection = useFilesCollection() const actions = createFileActions(collection) const { data: visible, isLoading: loading, isError, } = useLiveQuery({ queryKey: [did, spaceUri, parentId, search, sort, descending], query: (q) => fileQuery(q, collection, search, sort, descending, parentId), }) const { data: unsynced } = useLiveQuery({ query: (q) => q.from({ file: collection }).where(({ file }) => eq(file.$synced, false)), }) const pending = unsynced.length > 0 const busy = loading || isError || pending const loadError = isError ? collection.utils.lastError : undefined const showError = (cause: unknown) => setError(cause instanceof Error ? cause : new Error(String(cause))) const refresh = () => collection.utils.refetch({ throwOnError: true }).catch(showError) const rename = async (file: FileEntry, name: string) => { actions.rename(file, name).isPersisted.promise.catch(showError) return true } const selection = selectedEntries(visible, selected)
useEffect(() => { setSelected([]) setSearch("") setContext(null) }, [parentId])
const onOpen = (file: FileEntry) => { if (file.kind === "directory") setTrail([...trail, file]) else window.open(contentUrl(file.id), "_blank", "noopener") } const closeContext = useCallback(() => setContext(null), []) const deleteSelection = (files: FileEntry[]) => { if (!window.confirm(`Delete ${files.length} selected item(s)?`)) return for (const file of files) actions.trash(file).isPersisted.promise.catch(showError) setSelected([]) } const menuActions = (entries: FileEntry[]) => ({ onOpen: () => entries.forEach(onOpen), onDownload: () => entries .filter((file) => file.kind === "file") .forEach((file) => { const link = document.createElement("a") link.href = contentUrl(file.id) link.download = file.name link.click() }), onDelete: () => void deleteSelection(entries), onRenameRequest: entries.length === 1 ? () => setDialog({ kind: "rename", file: entries[0]! }) : undefined, onDuplicate: () => { for (const file of entries.filter((file) => file.kind === "file")) actions.duplicate(file, `Copy of ${file.name}`).isPersisted.promise.catch(showError) }, onInspect: () => setInspecting(true), onCopyCID: () => void navigator.clipboard .writeText(entries.map((file) => file.revision).join(",")) .catch(showError), }) useClickOutside({ ref: filesRef, handler: (event) => { if (event.ctrlKey || event.metaKey) return if ( event.target instanceof Element && event.target.closest("#file-toolbar, #file-inspector, .popover-menu, .popover") ) return setSelected([]) }, })
return ( <main className="files-main flex flex-col items-start lg:flex-row"> <div ref={filesRef} className="files-content w-full min-w-0 flex-1 lg:w-auto" onContextMenu={(event) => { if (event.target instanceof Element && event.target.closest("input, #file-toolbar")) return event.preventDefault() setContext({ top: event.clientY, left: event.clientX }) }} onClick={(event) => { if (event.target === event.currentTarget) setSelected([]) }} > <div className="files-heading relative"> <h1 className="breadcrumbs flex items-center pr-12 lg:items-end"> <button className="rounded-none p-0 text-left [font:inherit] hover:underline" disabled={busy} onClick={() => { setTrail([]) }} > Files </button> <span className="folder-trail text-sm/[inherit] text-[#404040] wrap-anywhere lg:text-lg/[inherit] dark:text-[#e5e5e5]"> {trail.map((folder, index) => ( <span key={folder.id}> <span aria-hidden="true" className="folder-separator mx-3 inline-block font-light text-[#737373]" > > </span> <button className="rounded-none p-0 text-left [font:inherit] hover:underline" disabled={busy} onClick={() => setTrail(trail.slice(0, index + 1))} > {folder.name} </button> </span> ))} </span> </h1> </div> <Toolbar search={search} onSearch={setSearch} sort={sort} onSort={setSort} view={view} onView={setView} pending={busy} onCreate={(kind) => setDialog({ kind })} onUpload={(files) => { for (const file of files) actions.upload(file, parentId).isPersisted.promise.catch(showError) }} /> {error && ( <div className="error mt-4 flex flex-wrap items-center gap-3" role="alert"> <span>{error.message}</span> <button onClick={() => setError(null)}>Dismiss</button> </div> )} {loadError && ( <div className="error mt-4" role="alert"> <p>{loadError instanceof Error ? loadError.message : String(loadError)}</p> <button onClick={() => void refresh()}>Retry</button> </div> )} {(loading || !loadError) && ( <FileList files={visible} loading={loading} emptyMessage={search ? "No matching files." : undefined} selected={selection.map((file) => file.id)} view={view} pending={busy} onSelect={(id, checked, additive) => setSelected( additive ? checked ? [...selected.filter((value) => value !== id), id] : selected.filter((value) => value !== id) : [id], ) } menuActions={menuActions} onDeselect={(ids) => setSelected((current) => current.filter((id) => !ids.includes(id))) } onRename={rename} onOpen={onOpen} /> )} </div> <aside id="file-inspector" aria-label="Inspector" aria-hidden={!inspecting} inert={!inspecting} className={twJoin( "shrink-0 overflow-hidden rounded-md bg-white shadow-md transition-all duration-300 dark:bg-neutral-800 lg:sticky lg:top-8 lg:mt-20 lg:h-fit", inspecting ? "mt-6 lg:ml-8 w-full opacity-100 lg:w-80" : "h-0 w-0 ml-0 opacity-0", )} > <div className="w-80"> <FileInspector selection={selection} directory={trail.at(-1) ?? { name: "Root", size: 0 }} path={trail.map((folder) => folder.name).join("/")} close={() => setInspecting(false)} /> </div> </aside> {context && ( <PopoverMenu anchor={context} close={closeContext} opts={[ makeOpt("New File", closeContext, () => setDialog({ kind: "file" }), false, busy), makeOpt( "New Folder", closeContext, () => setDialog({ kind: "directory" }), false, busy, ), "divider", makeOpt("Select all", closeContext, () => setSelected(visible.map((file) => file.id))), makeOpt("Clear selection", closeContext, () => setSelected([])), makeOpt(descending ? "Sort ascending" : "Sort descending", closeContext, () => setDescending(!descending), ), ]} /> )} {dialog && ( <NameDialog pending={busy} error={error?.message} title={ dialog.kind === "rename" ? "Rename" : dialog.kind === "directory" ? "New Folder" : "New File" } initial={"file" in dialog ? dialog.file.name : ""} onClose={() => setDialog(null)} onSave={async (name) => { const transaction = dialog.kind === "rename" ? actions.rename(dialog.file, name) : actions.create(name, dialog.kind, parentId) transaction.isPersisted.promise.catch(showError) return true }} /> )} </main> )}