diff --git a/frontend/src/components/common/S3FolderPicker.tsx b/frontend/src/components/common/S3FolderPicker.tsx new file mode 100644 index 0000000..eb606a2 --- /dev/null +++ b/frontend/src/components/common/S3FolderPicker.tsx @@ -0,0 +1,210 @@ +/** + * S3FolderPicker — browse the configured S3 bucket and choose a destination folder. + * + * Renders inline (not a modal). At any level the user can either: + * - sync into the folder they've navigated into, or + * - create a new subfolder at the current location. + * + * Calls `onSelect(prefix)` with the resolved key prefix (no leading slash, no + * trailing slash) whenever a valid destination is chosen. The bucket root is not + * a valid "folder" destination. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { apiGet } from '../../api/client.ts'; +import type { S3ListResponse } from '../../types/api.ts'; +import { CloudIcon, FolderIcon, RefreshIcon } from '../../utils/icons.tsx'; +import Breadcrumb from './Breadcrumb.tsx'; +import Spinner from './Spinner.tsx'; +import { type DestMode, resolveDestination } from './s3FolderPath.ts'; + +interface S3FolderPickerProps { + bucket: string; + /** Called with the resolved prefix (no surrounding slashes) when a destination is chosen. */ + onSelect: (prefix: string) => void; + /** + * Optional suggested top-level folder name. When provided, a shortcut button + * sets the destination to a new folder of this name at the bucket root. + */ + suggestedName?: string; +} + +export default function S3FolderPicker({ bucket, onSelect, suggestedName }: S3FolderPickerProps) { + const [prefix, setPrefix] = useState(''); + const [folders, setFolders] = useState<{ name: string; prefix: string }[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [mode, setMode] = useState('new'); + const [newName, setNewName] = useState(''); + + const fetchFolders = useCallback(async (currentPrefix: string) => { + setLoading(true); + setError(null); + try { + const params: Record = { delimiter: '/' }; + if (currentPrefix) params.prefix = currentPrefix; + const data = await apiGet('/api/files/list', params); + if (!data.success) { + setError(data.error ?? 'Failed to list folders'); + return; + } + setFolders(data.folders); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to list folders'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchFolders(prefix); + }, [prefix, fetchFolders]); + + // Breadcrumb: bucket root + each path segment + const breadcrumbItems = useMemo(() => { + const items: { label: string; onClick?: () => void }[] = [ + { label: bucket || 'bucket', onClick: prefix ? () => setPrefix('') : undefined }, + ]; + if (prefix) { + const parts = prefix.replace(/\/$/, '').split('/'); + for (let i = 0; i < parts.length; i++) { + const partPrefix = `${parts.slice(0, i + 1).join('/')}/`; + const isLast = i === parts.length - 1; + items.push({ label: parts[i], onClick: isLast ? undefined : () => setPrefix(partPrefix) }); + } + } + return items; + }, [bucket, prefix]); + + const atRoot = prefix === ''; + const currentFolderLabel = prefix.replace(/\/$/, '') || '(bucket root)'; + const destination = resolveDestination(prefix, mode, newName); + + // "Sync into this folder" is meaningless at the bucket root — force "new" there. + useEffect(() => { + if (atRoot && mode === 'existing') setMode('new'); + }, [atRoot, mode]); + + // Push the resolved destination up to the parent whenever it changes. + useEffect(() => { + onSelect(destination); + }, [destination, onSelect]); + + return ( +
+ {/* Header: bucket + breadcrumb + refresh */} +
+ +
+ +
+ {suggestedName && ( + + )} + +
+ + {/* Folder list */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : folders.length === 0 ? ( +
No subfolders here.
+ ) : ( +
    + {folders.map((folder) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Destination control */} +
+ {/* Sync into current folder */} + + + {/* Create new subfolder here */} + + + {/* Live destination preview */} +

+ Files land at{' '} + + s3://{bucket || ''}/{destination || '…'}/ + +

+
+
+ ); +} diff --git a/frontend/src/components/common/s3FolderPath.ts b/frontend/src/components/common/s3FolderPath.ts new file mode 100644 index 0000000..080242d --- /dev/null +++ b/frontend/src/components/common/s3FolderPath.ts @@ -0,0 +1,26 @@ +/** Pure helpers for resolving an S3 destination prefix from S3FolderPicker state. */ + +export type DestMode = 'existing' | 'new'; + +/** Trim, drop surrounding slashes, and collapse internal whitespace runs. */ +export function sanitizeFolderName(name: string): string { + return name + .trim() + .replace(/^\/+|\/+$/g, '') + .replace(/\s+/g, ' '); +} + +/** + * Resolve the destination prefix from the current browse location and form state. + * Returns '' when the destination is not a valid folder (e.g. bucket root, or an + * empty new-subfolder name). + */ +export function resolveDestination(prefix: string, mode: DestMode, newName: string): string { + const base = prefix.replace(/\/$/, ''); + if (mode === 'existing') { + return base; // '' at bucket root → invalid + } + const clean = sanitizeFolderName(newName); + if (!clean) return ''; + return base ? `${base}/${clean}` : clean; +} diff --git a/frontend/src/pages/LargeFolderUploadPage.tsx b/frontend/src/pages/LargeFolderUploadPage.tsx index b1a11df..e5ec8c8 100644 --- a/frontend/src/pages/LargeFolderUploadPage.tsx +++ b/frontend/src/pages/LargeFolderUploadPage.tsx @@ -8,13 +8,15 @@ */ import { useVirtualizer } from '@tanstack/react-virtual'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { cancelLargeFolderSync, startLargeFolderSync } from '../api/largeFolderUpload.ts'; import FolderPicker from '../components/common/FolderPicker.tsx'; +import S3FolderPicker from '../components/common/S3FolderPicker.tsx'; import { useAppStore } from '../stores/appStore.ts'; import { + defaultPrefix, type SyncProgress, type SyncStatus, useLargeFolderUploadStore, @@ -284,6 +286,9 @@ function NamePrefixStep() { const settings = useAppStore((s) => s.settings); const notifications = useAppStore((s) => s.addNotification); + // Stable per-mount timestamp folder name for the "Use timestamp default" shortcut. + const suggestedName = useMemo(() => defaultPrefix(), []); + const handleBack = useCallback(() => { // Return to folder picker — clear folderPath so SetupPhase shows picker again setFolderPath(''); @@ -319,35 +324,20 @@ function NamePrefixStep() {

- Name your cloud destination folder + Choose your cloud destination folder

- The default name is fine. It's a timestamp so each - upload gets its own folder in NLR Cloud Storage and nothing gets overwritten. You can - change it if you're continuing a previous upload or want a friendlier name. + Browse NLR Cloud Storage and pick an existing folder to add to it, or create a new + subfolder. Already-uploaded files are skipped automatically — safe to re-run.
-
- - setS3Prefix(e.target.value)} - className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-nlr-blue" - placeholder="user_upload_2025-01-01T12-00-00" - // biome-ignore lint/a11y/noAutofocus: focus is intentional — user is here to name the folder - autoFocus - /> -

- Files land at{' '} - s3://<bucket>/{s3Prefix.trim() || '…'}/ -

-
+