Skip to content

Commit 1e73a72

Browse files
Merge pull request #15 from solid/feat/my-storages
Feat/my storages
2 parents d5d72ce + 81bbb85 commit 1e73a72

7 files changed

Lines changed: 555 additions & 181 deletions

File tree

app/components/FileItem.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface FileItemProps {
2424
onRename?: (file: FileItemData) => void;
2525
onPreview?: (file: FileItemData) => void;
2626
onCopy?: (file: FileItemData) => void;
27+
onMove?: (file: FileItemData) => void;
2728
isSelected?: boolean;
2829
}
2930

@@ -35,6 +36,7 @@ export default function FileItem({
3536
onRename,
3637
onPreview,
3738
onCopy,
39+
onMove,
3840
isSelected = false,
3941
}: FileItemProps) {
4042
const [isHovered, setIsHovered] = useState(false);
@@ -131,7 +133,7 @@ export default function FileItem({
131133
onPreview={onPreview}
132134
onDownload={(f) => console.log("Download:", f.name)}
133135
onCopy={onCopy}
134-
onMove={(f) => console.log("Move:", f.name)}
136+
onMove={onMove}
135137
onDelete={(f) => console.log("Delete:", f.name)}
136138
/>
137139
)}
@@ -179,7 +181,7 @@ export default function FileItem({
179181
onPreview={onPreview}
180182
onDownload={(f) => console.log("Download:", f.name)}
181183
onCopy={onCopy}
182-
onMove={(f) => console.log("Move:", f.name)}
184+
onMove={onMove}
183185
onDelete={(f) => console.log("Delete:", f.name)}
184186
/>
185187
)}

app/components/FileItemMenu.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,18 @@ export default function FileItemMenu({
104104
className: "text-gray-700 hover:bg-gray-100 border-b border-gray-100",
105105
iconClassName: "text-gray-500",
106106
},
107-
{
108-
label: "Move",
109-
icon: ArrowRightCircleIcon,
110-
action: onMove,
111-
className: "text-gray-700 hover:bg-gray-100 border-b border-gray-100",
112-
iconClassName: "text-gray-500",
113-
},
107+
// Only show Move for files, not folders
108+
...(file.type === "file"
109+
? [
110+
{
111+
label: "Move",
112+
icon: ArrowRightCircleIcon,
113+
action: onMove,
114+
className: "text-gray-700 hover:bg-gray-100 border-b border-gray-100",
115+
iconClassName: "text-gray-500",
116+
},
117+
]
118+
: []),
114119
{
115120
label: "Delete",
116121
icon: TrashIcon,

app/components/FileList.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface FileListProps {
1313
onFileRename?: (file: FileItemData) => void;
1414
onFilePreview?: (file: FileItemData) => void;
1515
onFileCopy?: (file: FileItemData) => void;
16+
onFileMove?: (file: FileItemData) => void;
1617
selectedFileIds: string[];
1718
}
1819

@@ -26,6 +27,7 @@ export default function FileList({
2627
onFileRename,
2728
onFilePreview,
2829
onFileCopy,
30+
onFileMove,
2931
selectedFileIds,
3032
}: FileListProps) {
3133
const [view, setView] = useState<"grid" | "list">(() => {
@@ -62,6 +64,7 @@ export default function FileList({
6264
onRename={onFileRename}
6365
onPreview={onFilePreview}
6466
onCopy={onFileCopy}
67+
onMove={onFileMove}
6568
isSelected={selectedFileIds.includes(file.id)}
6669
/>
6770
))}
@@ -78,6 +81,7 @@ export default function FileList({
7881
onRename={onFileRename}
7982
onPreview={onFilePreview}
8083
onCopy={onFileCopy}
84+
onMove={onFileMove}
8185
isSelected={selectedFileIds.includes(file.id)}
8286
/>
8387
))}

app/components/FileManager.tsx

Lines changed: 44 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,6 @@
22

33
import { useState, useEffect } from "react";
44
import toast from "react-hot-toast";
5-
import {
6-
getFile,
7-
overwriteFile,
8-
createContainerAt,
9-
getSolidDataset,
10-
getContainedResourceUrlAll,
11-
UrlString,
12-
} from "@inrupt/solid-client";
135
import { useSearchParams, useRouter } from "next/navigation";
146
import AuthWrapper from "./AuthWrapper";
157
import Header from "./Header";
@@ -20,6 +12,7 @@ import PermissionsDialog, { Permission } from "./PermissionsDialog";
2012
import NewFolderDialog from "./NewFolderDialog";
2113
import RenameDialog from "./RenameDialog";
2214
import PreviewModal from "./PreviewModal";
15+
import MoveDialog from "./MoveDialog";
2316
import FileUploadHandler from "./FileUploadHandler";
2417
import { FileItemData } from "./FileItem";
2518
import LoadingSpinner from "./shared/LoadingSpinner";
@@ -28,172 +21,10 @@ import { useSolidStorages, useBrowseStorage } from "../lib/hooks";
2821
import {
2922
buildBreadcrumbItems,
3023
getAuthenticatedSession,
31-
getDisplayNameFromMeta,
32-
updateMetaFile,
24+
copyFileResource,
25+
copyFolderResource,
3326
} from "../lib/helpers";
3427

35-
const INVALID_NAME_CHARS = /[<>:"/\\|?*]/g;
36-
37-
const sanitizeResourceName = (name: string): string => {
38-
const sanitized = name.replace(INVALID_NAME_CHARS, "").trim();
39-
return sanitized || "Untitled";
40-
};
41-
42-
const decodeResourceNameFromUrl = (resourceUrl: string): string => {
43-
try {
44-
const urlObj = new URL(resourceUrl);
45-
const segments = urlObj.pathname.split("/").filter(Boolean);
46-
if (segments.length === 0) {
47-
return urlObj.hostname;
48-
}
49-
const lastSegment = resourceUrl.endsWith("/") ? segments[segments.length - 1] : segments[segments.length - 1];
50-
return decodeURIComponent(lastSegment);
51-
} catch {
52-
return resourceUrl;
53-
}
54-
};
55-
56-
const ensureTrailingSlash = (url: string): string => (url.endsWith("/") ? url : `${url}/`);
57-
58-
const getParentContainerUrl = (resourceUrl: string): string => {
59-
try {
60-
const urlObj = new URL(resourceUrl);
61-
const segments = urlObj.pathname.split("/").filter(Boolean);
62-
if (segments.length === 0) {
63-
return `${urlObj.origin}/`;
64-
}
65-
if (!resourceUrl.endsWith("/")) {
66-
segments.pop();
67-
} else if (segments.length > 0) {
68-
segments.pop();
69-
}
70-
const parentPath = segments.length ? `/${segments.join("/")}/` : "/";
71-
return `${urlObj.origin}${parentPath}`;
72-
} catch {
73-
return resourceUrl;
74-
}
75-
};
76-
77-
const shouldSkipResourceCopy = (resourceUrl: string): boolean => {
78-
return resourceUrl.endsWith(".meta") || resourceUrl.endsWith(".acl");
79-
};
80-
81-
const resourceExists = async (url: string, fetchFn: typeof fetch): Promise<boolean> => {
82-
try {
83-
const response = await fetchFn(url, { method: "HEAD" });
84-
if (response.status === 404) {
85-
return false;
86-
}
87-
if (response.status >= 200 && response.status < 300) {
88-
return true;
89-
}
90-
// For other statuses (401, 403, 405, etc.) assume the resource exists to avoid collisions
91-
return true;
92-
} catch {
93-
return false;
94-
}
95-
};
96-
97-
const generateCopyTarget = async (
98-
parentUrl: string,
99-
desiredName: string,
100-
isContainer: boolean,
101-
fetchFn: typeof fetch
102-
): Promise<{ targetUrl: string; displayName: string }> => {
103-
const parentWithSlash = ensureTrailingSlash(parentUrl);
104-
let attempt = 0;
105-
106-
while (attempt < 100) {
107-
const candidateDisplayName = attempt === 0 ? desiredName : `${desiredName} (${attempt})`;
108-
const candidatePathName = sanitizeResourceName(candidateDisplayName);
109-
const encodedName = encodeURIComponent(candidatePathName);
110-
const candidateUrl = isContainer ? `${parentWithSlash}${encodedName}/` : `${parentWithSlash}${encodedName}`;
111-
const exists = await resourceExists(candidateUrl, fetchFn);
112-
if (!exists) {
113-
return { targetUrl: candidateUrl, displayName: candidateDisplayName };
114-
}
115-
attempt += 1;
116-
}
117-
118-
throw new Error("Unable to generate a unique name for the copy");
119-
};
120-
121-
const copyFileFromSource = async (
122-
sourceUrl: string,
123-
targetUrl: string,
124-
displayName: string,
125-
fetchFn: typeof fetch,
126-
mimeTypeHint?: string
127-
): Promise<void> => {
128-
const fileBlob = await getFile(sourceUrl as UrlString, { fetch: fetchFn });
129-
const contentType = fileBlob.type || mimeTypeHint || "application/octet-stream";
130-
await overwriteFile(targetUrl as UrlString, fileBlob, {
131-
fetch: fetchFn,
132-
contentType,
133-
});
134-
await updateMetaFile(targetUrl as UrlString, displayName, fetchFn);
135-
};
136-
137-
const copyFolderContents = async (
138-
sourceFolderUrl: string,
139-
destinationFolderUrl: string,
140-
fetchFn: typeof fetch
141-
): Promise<void> => {
142-
const dataset = await getSolidDataset(sourceFolderUrl, { fetch: fetchFn });
143-
const containedResources = getContainedResourceUrlAll(dataset);
144-
145-
for (const resourceUrl of containedResources) {
146-
if (shouldSkipResourceCopy(resourceUrl)) {
147-
continue;
148-
}
149-
150-
if (resourceUrl.endsWith("/")) {
151-
const childName = decodeResourceNameFromUrl(resourceUrl);
152-
const encodedChildName = encodeURIComponent(childName);
153-
const childDestination = `${ensureTrailingSlash(destinationFolderUrl)}${encodedChildName}/`;
154-
155-
await createContainerAt(childDestination as UrlString, { fetch: fetchFn });
156-
const childDisplayName =
157-
(await getDisplayNameFromMeta(resourceUrl, fetchFn)) ?? childName;
158-
await updateMetaFile(childDestination as UrlString, childDisplayName, fetchFn);
159-
160-
await copyFolderContents(resourceUrl, childDestination, fetchFn);
161-
} else {
162-
const childName = decodeResourceNameFromUrl(resourceUrl);
163-
const encodedChildName = encodeURIComponent(childName);
164-
const childDestination = `${ensureTrailingSlash(destinationFolderUrl)}${encodedChildName}`;
165-
const childDisplayName =
166-
(await getDisplayNameFromMeta(resourceUrl, fetchFn)) ?? childName;
167-
await copyFileFromSource(resourceUrl, childDestination, childDisplayName, fetchFn);
168-
}
169-
}
170-
};
171-
172-
const copyFileResource = async (file: FileItemData, fetchFn: typeof fetch): Promise<void> => {
173-
const originalLabel =
174-
(await getDisplayNameFromMeta(file.url, fetchFn)) ??
175-
file.name ??
176-
decodeResourceNameFromUrl(file.url);
177-
const parentUrl = getParentContainerUrl(file.url);
178-
const desiredName = `Copy of ${originalLabel}`;
179-
const { targetUrl, displayName } = await generateCopyTarget(parentUrl, desiredName, false, fetchFn);
180-
await copyFileFromSource(file.url, targetUrl, displayName, fetchFn, file.mimeType);
181-
};
182-
183-
const copyFolderResource = async (folder: FileItemData, fetchFn: typeof fetch): Promise<void> => {
184-
const originalLabel =
185-
(await getDisplayNameFromMeta(folder.url, fetchFn)) ??
186-
folder.name ??
187-
decodeResourceNameFromUrl(folder.url);
188-
const parentUrl = getParentContainerUrl(folder.url);
189-
const desiredName = `Copy of ${originalLabel}`;
190-
const { targetUrl, displayName } = await generateCopyTarget(parentUrl, desiredName, true, fetchFn);
191-
192-
await createContainerAt(targetUrl as UrlString, { fetch: fetchFn });
193-
await updateMetaFile(targetUrl as UrlString, displayName, fetchFn);
194-
await copyFolderContents(folder.url, targetUrl, fetchFn);
195-
};
196-
19728

19829
export default function FileManager() {
19930
const searchParams = useSearchParams();
@@ -216,6 +47,8 @@ export default function FileManager() {
21647
const [fileToRename, setFileToRename] = useState<FileItemData | null>(null);
21748
const [showPreviewModal, setShowPreviewModal] = useState(false);
21849
const [fileToPreview, setFileToPreview] = useState<FileItemData | null>(null);
50+
const [showMoveDialog, setShowMoveDialog] = useState(false);
51+
const [fileToMove, setFileToMove] = useState<FileItemData | null>(null);
21952

22053
const [savedUrl, setSavedUrl] = useState<string | null>(() => {
22154
if (typeof window === "undefined") return null;
@@ -420,6 +253,15 @@ export default function FileManager() {
420253
setShowPreviewModal(true);
421254
};
422255

256+
const handleMove = (file: FileItemData) => {
257+
setFileToMove(file);
258+
setShowMoveDialog(true);
259+
};
260+
261+
const handleMoved = () => {
262+
setRefreshKey((prev) => prev + 1);
263+
};
264+
423265
const storageFiles: FileItemData[] = storages.map((storage) => ({
424266
id: storage.id,
425267
name: storage.name,
@@ -429,6 +271,24 @@ export default function FileManager() {
429271

430272
const displayFiles = selectedStorageId ? browsedFiles : storageFiles;
431273

274+
// Get all available folders for move dialog (storages + browsed folders)
275+
const availableFolders: FileItemData[] = [
276+
...storageFiles,
277+
...(selectedStorageId ? browsedFiles.filter((f) => f.type === "folder") : []),
278+
];
279+
280+
// Get current location URL for move dialog
281+
const getCurrentLocationUrl = (): string => {
282+
if (!selectedStorageId) {
283+
return "";
284+
}
285+
if (currentPath === "/") {
286+
const storage = storages.find((s) => s.id === selectedStorageId);
287+
return storage?.url || "";
288+
}
289+
return currentPath;
290+
};
291+
432292
const selectedStorage = storages.find((s) => s.id === selectedStorageId);
433293
const breadcrumbItems = buildBreadcrumbItems(
434294
selectedStorageId,
@@ -606,6 +466,7 @@ export default function FileManager() {
606466
onFileRename={handleRename}
607467
onFilePreview={handlePreview}
608468
onFileCopy={handleCopy}
469+
onFileMove={handleMove}
609470
selectedFileIds={selectedFileIds}
610471
/>
611472
</div>
@@ -649,6 +510,17 @@ export default function FileManager() {
649510
}}
650511
file={fileToPreview}
651512
/>
513+
<MoveDialog
514+
isOpen={showMoveDialog}
515+
onClose={() => {
516+
setShowMoveDialog(false);
517+
setFileToMove(null);
518+
}}
519+
file={fileToMove}
520+
availableFolders={availableFolders}
521+
currentLocationUrl={getCurrentLocationUrl()}
522+
onMoved={handleMoved}
523+
/>
652524
<FileUploadHandler
653525
currentContainerUrl={containerUrlToBrowse}
654526
onUploadComplete={handleFileUploaded}

0 commit comments

Comments
 (0)