void;
+ onUploadToFolder: (containerUrl: string, files: File[]) => void;
viewMode: ViewMode;
searchQuery: string;
}) {
@@ -680,6 +729,7 @@ function Listing({
driveRoot={driveRoot}
containerUrl={containerUrl}
onChanged={onChanged}
+ onUploadToFolder={onUploadToFolder}
/>
))}
@@ -694,27 +744,71 @@ function Listing({
driveRoot={driveRoot}
containerUrl={containerUrl}
onChanged={onChanged}
+ onUploadToFolder={onUploadToFolder}
/>
))}
);
}
+/**
+ * Drag-and-drop handlers that let a folder row/tile accept files dropped onto
+ * it, uploading INTO that folder. We stop propagation so the page-level
+ * Dropzone (which uploads to the current folder) doesn't also fire. Non-folder
+ * entries opt out, letting the event bubble to the page Dropzone.
+ */
+function useFolderDrop(
+ isFolder: boolean,
+ folderUrl: string,
+ onUploadToFolder: (containerUrl: string, files: File[]) => void,
+) {
+ const [over, setOver] = useState(false);
+ if (!isFolder) return { over: false, handlers: {} as Record
};
+ return {
+ over,
+ handlers: {
+ onDragOver: (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setOver(true);
+ },
+ onDragLeave: (e: React.DragEvent) => {
+ if (e.currentTarget === e.target) setOver(false);
+ },
+ onDrop: (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setOver(false);
+ if (e.dataTransfer.files.length > 0) {
+ onUploadToFolder(folderUrl, Array.from(e.dataTransfer.files));
+ }
+ },
+ },
+ };
+}
+
function Tile({
entry,
driveRoot,
onChanged,
+ onUploadToFolder,
}: {
entry: PodEntry;
driveRoot: string;
containerUrl: string;
onChanged: () => void;
+ onUploadToFolder: (containerUrl: string, files: File[]) => void;
}) {
const relPath = entry.url.slice(driveRoot.length);
const href =
entry.kind === "container" ? "/drive/" + relPath.replace(/\/$/, "") : "/drive/file/" + relPath;
const [busy, setBusy] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
+ const { over: dropOver, handlers: dropHandlers } = useFolderDrop(
+ entry.kind === "container",
+ entry.url,
+ onUploadToFolder,
+ );
async function doDelete() {
setBusy(true);
try {
@@ -734,7 +828,10 @@ function Tile({
const isContainer = entry.kind === "container";
return (
@@ -797,20 +894,34 @@ function Row({
entry,
driveRoot,
onChanged,
+ onUploadToFolder,
}: {
entry: PodEntry;
driveRoot: string;
containerUrl: string;
onChanged: () => void;
+ onUploadToFolder: (containerUrl: string, files: File[]) => void;
}) {
const relPath = entry.url.slice(driveRoot.length);
const href =
entry.kind === "container" ? "/drive/" + relPath.replace(/\/$/, "") : "/drive/file/" + relPath;
+ const isContainer = entry.kind === "container";
+ // The user-facing name (decoded; encrypted files show their original name).
+ const displayedName = isEncryptedName(safeDecode(entry.name))
+ ? originalNameFromEnc(safeDecode(entry.name))
+ : safeDecode(entry.name);
+ // Renames edit the base only; the extension is locked so the format can't change.
+ const { base, ext } = splitExt(displayedName);
const [busy, setBusy] = useState(false);
const [renaming, setRenaming] = useState(false);
- const [newName, setNewName] = useState(entry.name);
+ const [newBase, setNewBase] = useState(base);
const [confirmOpen, setConfirmOpen] = useState(false);
- const isContainer = entry.kind === "container";
+ const { over: dropOver, handlers: dropHandlers } = useFolderDrop(
+ isContainer,
+ entry.url,
+ onUploadToFolder,
+ );
+ const isImage = entry.kind === "resource" && isImageName(safeDecode(entry.name));
async function doDelete() {
setBusy(true);
@@ -835,14 +946,7 @@ function Row({
const res = await podFetch()(entry.url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const blob = await res.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = entry.name;
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => URL.revokeObjectURL(url), 1000);
+ saveBlob(blob, safeDecode(entry.name));
} catch (err) {
toast.error(`Download failed: ${String(err)}`);
} finally {
@@ -852,10 +956,10 @@ function Row({
async function onRenameSubmit(e: React.FormEvent) {
e.preventDefault();
- const trimmed = newName.trim();
- if (!trimmed || trimmed === entry.name) {
+ const nextDisplay = newBase.trim() + ext;
+ if (!newBase.trim() || nextDisplay === displayedName) {
setRenaming(false);
- setNewName(entry.name);
+ setNewBase(base);
return;
}
if (entry.kind === "container") {
@@ -865,9 +969,7 @@ function Row({
}
setBusy(true);
try {
- const parent = entry.url.slice(0, entry.url.lastIndexOf("/") + 1);
- const target = parent + encodeURIComponent(trimmed);
- await rename(entry.url, target);
+ await renameFile({ fromUrl: entry.url, newDisplayName: nextDisplay });
setRenaming(false);
onChanged();
} catch (err) {
@@ -878,18 +980,32 @@ function Row({
}
return (
-
-
+
+ {isImage ? (
+
+
+
+ ) : (
+
+ )}
{renaming ? (
diff --git a/src/components/VaultDialog.tsx b/src/components/VaultDialog.tsx
new file mode 100644
index 0000000..656ea00
--- /dev/null
+++ b/src/components/VaultDialog.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Label,
+} from "@mind-studio/ui";
+import { Copy, ExternalLink, Eye, EyeOff } from "lucide-react";
+import Link from "next/link";
+import { useCallback, useEffect, useState } from "react";
+import { copyText } from "@/lib/clipboard";
+import { getSessionPassphrase, setSessionPassphrase } from "@/lib/solid/crypto";
+import { loadVault, searchVaultEntries, type VaultEntry } from "@/lib/solid/vault";
+
+/**
+ * The passphrase vault: an in-pod, encrypted index of the files you've
+ * encrypted, linking each to the passphrase that opens it. Unlocked with the
+ * same passphrase used to encrypt this session's files; if one is already held
+ * in memory the vault opens straight away.
+ */
+export default function VaultDialog({
+ driveRoot,
+ onClose,
+}: {
+ driveRoot: string;
+ onClose: () => void;
+}) {
+ const [unlocked, setUnlocked] = useState(false);
+ const [pass, setPass] = useState("");
+ const [entries, setEntries] = useState