profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
FolderBrowser.tsx 4,944 bytes
1 import { useEffect, useRef, useState } from 'react'
2 import type { FsBrowseResponse } from 'shared/types'
3 import * as api from '../api'
4
5 interface FolderBrowserProps {
6 onSelect: (path: string) => void
7 onClose: () => void
8 }
9
10 export function FolderBrowser({ onSelect, onClose }: FolderBrowserProps) {
11 const [listing, setListing] = useState<FsBrowseResponse | null>(null)
12 const [error, setError] = useState<string | null>(null)
13 const [newFolderName, setNewFolderName] = useState('')
14 const [creating, setCreating] = useState(false)
15 const dialogRef = useRef<HTMLDivElement>(null)
16
17 function load(path?: string) {
18 setError(null)
19 api.browseFolder(path).then(setListing).catch((err: Error) => setError(err.message))
20 }
21
22 useEffect(() => {
23 load(undefined)
24 }, [])
25
26 useEffect(() => {
27 const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
28 const dialog = dialogRef.current
29 dialog?.focus()
30
31 function handleKeyDown(event: KeyboardEvent) {
32 if (event.key === 'Escape') {
33 onClose()
34 return
35 }
36 if (event.key !== 'Tab' || !dialog) return
37
38 const focusable = Array.from(
39 dialog.querySelectorAll<HTMLElement>(
40 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])',
41 ),
42 )
43 if (focusable.length === 0) {
44 event.preventDefault()
45 return
46 }
47
48 const first = focusable[0]
49 const last = focusable[focusable.length - 1]
50 const active = document.activeElement
51 if (active === dialog) {
52 event.preventDefault()
53 const target = event.shiftKey ? last : first
54 target?.focus()
55 } else if (event.shiftKey && active === first) {
56 event.preventDefault()
57 last?.focus()
58 } else if (!event.shiftKey && active === last) {
59 event.preventDefault()
60 first?.focus()
61 }
62 }
63 window.addEventListener('keydown', handleKeyDown)
64 return () => {
65 window.removeEventListener('keydown', handleKeyDown)
66 previousFocus?.focus()
67 }
68 }, [onClose])
69
70 async function handleCreateFolder() {
71 if (!listing || !newFolderName.trim()) return
72 setCreating(true)
73 setError(null)
74 try {
75 const created = await api.createFolder(listing.path, newFolderName.trim())
76 setNewFolderName('')
77 load(created.path)
78 } catch (err) {
79 setError(err instanceof Error ? err.message : String(err))
80 } finally {
81 setCreating(false)
82 }
83 }
84
85 return (
86 <div className="folder-browser-overlay" onClick={onClose}>
87 <div
88 ref={dialogRef}
89 className="folder-browser"
90 role="dialog"
91 aria-modal="true"
92 aria-labelledby="folder-browser-title"
93 tabIndex={-1}
94 onClick={(event) => event.stopPropagation()}
95 >
96 <h2 id="folder-browser-title">Choose a folder</h2>
97 <p className="folder-browser-path">{listing?.path ?? 'Loading...'}</p>
98
99 <div className="folder-browser-list">
100 {listing?.parent && (
101 <button type="button" className="folder-browser-entry" onClick={() => load(listing.parent!)}>
102 Up one level
103 </button>
104 )}
105 {listing?.directories.map((directory) => (
106 <button
107 type="button"
108 key={directory.path}
109 className="folder-browser-entry"
110 onClick={() => load(directory.path)}
111 >
112 {directory.name}
113 </button>
114 ))}
115 {listing && listing.directories.length === 0 && !listing.parent && (
116 <p className="muted">No folders here.</p>
117 )}
118 {listing && listing.directories.length === 0 && listing.parent && (
119 <p className="muted">No subfolders here.</p>
120 )}
121 </div>
122
123 <div className="folder-browser-new">
124 <input
125 value={newFolderName}
126 onChange={(event) => setNewFolderName(event.target.value)}
127 placeholder="New folder name"
128 aria-label="New folder name"
129 disabled={creating}
130 />
131 <button
132 type="button"
133 onClick={() => void handleCreateFolder()}
134 disabled={creating || !newFolderName.trim()}
135 >
136 {creating ? 'Creating...' : 'Create folder'}
137 </button>
138 </div>
139
140 {error && (
141 <p className="error" role="alert">
142 {error}
143 </p>
144 )}
145
146 <div className="folder-browser-actions">
147 <button type="button" onClick={onClose}>
148 Cancel
149 </button>
150 <button
151 type="button"
152 className="primary"
153 disabled={!listing}
154 onClick={() => listing && onSelect(listing.path)}
155 >
156 Use this folder
157 </button>
158 </div>
159 </div>
160 </div>
161 )
162 }
163