Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions frontend/src/components/common/S3FolderPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

const [mode, setMode] = useState<DestMode>('new');
const [newName, setNewName] = useState('');

const fetchFolders = useCallback(async (currentPrefix: string) => {
setLoading(true);
setError(null);
try {
const params: Record<string, string> = { delimiter: '/' };
if (currentPrefix) params.prefix = currentPrefix;
const data = await apiGet<S3ListResponse>('/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 (
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white">
{/* Header: bucket + breadcrumb + refresh */}
<div className="px-4 py-2.5 bg-gray-50 border-b border-gray-200 flex items-center gap-3">
<CloudIcon className="w-5 h-5 text-nlr-blue flex-shrink-0" />
<div className="min-w-0 flex-1">
<Breadcrumb items={breadcrumbItems} />
</div>
{suggestedName && (
<button
type="button"
onClick={() => {
setPrefix('');
setMode('new');
setNewName(suggestedName);
}}
className="text-xs text-nlr-blue hover:underline flex-shrink-0"
>
Use timestamp default
</button>
)}
<button
type="button"
onClick={() => void fetchFolders(prefix)}
className="text-gray-400 hover:text-nlr-blue transition-colors flex-shrink-0"
aria-label="Refresh folder list"
>
<RefreshIcon className="w-4 h-4" />
</button>
</div>

{/* Folder list */}
<div className="max-h-64 overflow-y-auto">
{loading ? (
<div className="py-8 flex justify-center">
<Spinner size="sm" message="Loading folders…" />
</div>
) : error ? (
<div className="px-4 py-6 text-sm text-red-600">{error}</div>
) : folders.length === 0 ? (
<div className="px-4 py-6 text-sm text-gray-400 italic">No subfolders here.</div>
) : (
<ul className="divide-y divide-gray-100">
{folders.map((folder) => (
<li key={folder.prefix}>
<button
type="button"
onClick={() => setPrefix(folder.prefix)}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-left hover:bg-gray-50 transition-colors"
>
<FolderIcon className="w-4 h-4 text-amber-400 flex-shrink-0" />
<span className="truncate text-gray-800">{folder.name}</span>
</button>
</li>
))}
</ul>
)}
</div>

{/* Destination control */}
<div className="border-t border-gray-200 p-4 space-y-3 bg-gray-50/50">
{/* Sync into current folder */}
<label
className={`flex items-start gap-2 text-sm ${atRoot ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<input
type="radio"
name="dest-mode"
checked={mode === 'existing'}
disabled={atRoot}
onChange={() => setMode('existing')}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="font-medium text-nlr-text">Sync into this folder</span>
<span className="block font-mono text-xs text-gray-500 truncate">
{currentFolderLabel}
</span>
</span>
</label>

{/* Create new subfolder here */}
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input
type="radio"
name="dest-mode"
checked={mode === 'new'}
onChange={() => setMode('new')}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="font-medium text-nlr-text">Create new subfolder here</span>
<input
type="text"
value={newName}
onChange={(e) => {
setMode('new');
setNewName(e.target.value);
}}
placeholder="new-folder-name"
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-nlr-blue"
/>
</span>
</label>

{/* Live destination preview */}
<p className="text-xs text-gray-500 pt-1">
Files land at{' '}
<span className="font-mono text-gray-700">
s3://{bucket || '<bucket>'}/{destination || '…'}/
</span>
</p>
</div>
</div>
);
}
26 changes: 26 additions & 0 deletions frontend/src/components/common/s3FolderPath.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 14 additions & 24 deletions frontend/src/pages/LargeFolderUploadPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -319,35 +324,20 @@ function NamePrefixStep() {
<div className="flex items-center gap-2">
<CloudIcon className="w-5 h-5 text-nlr-blue" />
<h3 className="text-base font-semibold text-nlr-text">
Name your cloud destination folder
Choose your cloud destination folder
</h3>
</div>

<div className="bg-blue-50 border border-blue-200 rounded-md px-3 py-2 text-sm text-blue-800">
<span className="font-semibold">The default name is fine.</span> 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.
</div>

<div>
<label htmlFor="s3-prefix" className="block text-sm font-medium text-nlr-text mb-1">
Destination folder name
</label>
<input
id="s3-prefix"
type="text"
value={s3Prefix}
onChange={(e) => 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
/>
<p className="text-xs text-gray-400 mt-1">
Files land at{' '}
<span className="font-mono">s3://&lt;bucket&gt;/{s3Prefix.trim() || '…'}/</span>
</p>
</div>
<S3FolderPicker
bucket={settings?.s3_bucket ?? ''}
onSelect={setS3Prefix}
suggestedName={suggestedName}
/>

<div className="flex gap-3 pt-1">
<button
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/stores/largeFolderUploadStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ interface LargeFolderUploadState {
reset: () => void;
}

function defaultPrefix(): string {
export function defaultPrefix(): string {
const now = new Date();
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
return `user_upload_${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
Expand All @@ -50,7 +50,7 @@ const initialState = {
s3Uri: null,
cmd: null,
folderPath: null,
s3Prefix: defaultPrefix(),
s3Prefix: '',
lines: [],
status: null,
returnCode: null,
Expand Down Expand Up @@ -95,5 +95,5 @@ export const useLargeFolderUploadStore = create<LargeFolderUploadState>((set, ge

setError: (error) => set({ error, phase: 'done', status: 'failed' }),

reset: () => set({ ...initialState, s3Prefix: defaultPrefix() }),
reset: () => set({ ...initialState }),
}));
Loading
Loading