Skip to content

Commit df32808

Browse files
authored
Merge branch 'main' into main
2 parents 08db924 + 86fc623 commit df32808

File tree

12 files changed

+751
-152
lines changed

12 files changed

+751
-152
lines changed

src/hooks/useFileDialog.js

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
// src/hooks/useFileDialog.js
2+
import React, { useState, useEffect } from 'react';
3+
import { useTheme } from '@/context/ThemeContext';
4+
import { useFileOperations } from './useFileOperations';
5+
import {
6+
XMarkIcon,
7+
FolderIcon,
8+
CloudIcon,
9+
ComputerDesktopIcon,
10+
} from '@heroicons/react/24/outline';
11+
12+
const FileDialog = ({
13+
isOpen,
14+
onClose,
15+
onSelect,
16+
mode = 'open',
17+
defaultName = '',
18+
permittedExtensions = [],
19+
showExtension = true,
20+
}) => {
21+
const { theme } = useTheme();
22+
const { getFiles, createFile } = useFileOperations();
23+
const [files, setFiles] = useState([]);
24+
const [isLoading, setIsLoading] = useState(true);
25+
const [fileName, setFileName] = useState(defaultName);
26+
const [showNewMenu, setShowNewMenu] = useState(false);
27+
const [currentLocation, setCurrentLocation] = useState('drive');
28+
const [authStatus, setAuthStatus] = useState({ connected: false });
29+
const [currentPath, setCurrentPath] = useState('/');
30+
const [fileExtension, setFileExtension] = useState('');
31+
32+
const checkAuthStatus = async () => {
33+
try {
34+
const res = await fetch('/api/auth/status');
35+
const status = await res.json();
36+
setAuthStatus(status);
37+
return status;
38+
} catch (error) {
39+
console.error('Failed to check auth status:', error);
40+
return { connected: false };
41+
}
42+
};
43+
44+
const loadFiles = async () => {
45+
setIsLoading(true);
46+
try {
47+
if (currentLocation === 'drive') {
48+
const status = await checkAuthStatus();
49+
if (status.connected) {
50+
const data = await getFiles();
51+
setFiles(filterFilesByExtension(data));
52+
} else {
53+
setFiles([]);
54+
}
55+
} else {
56+
setFiles([]);
57+
}
58+
} catch (error) {
59+
console.error('Failed to load files:', error);
60+
} finally {
61+
setIsLoading(false);
62+
}
63+
};
64+
65+
const filterFilesByExtension = (fileList) => {
66+
if (!permittedExtensions.length) return fileList;
67+
return fileList.filter((file) => {
68+
const ext = file.name.split('.').pop()?.toLowerCase();
69+
return permittedExtensions.some(
70+
(allowed) => allowed.toLowerCase() === ext,
71+
);
72+
});
73+
};
74+
75+
const validateFileName = (name) => {
76+
if (!name.trim()) return 'File name cannot be empty';
77+
if (name.length > 255) return 'File name too long (max 255 characters)';
78+
if (/[<>:"/\\|?*]/.test(name))
79+
return 'File name contains invalid characters';
80+
if (permittedExtensions.length) {
81+
const ext = name.split('.').pop()?.toLowerCase();
82+
if (
83+
!ext ||
84+
!permittedExtensions.some((allowed) => allowed.toLowerCase() === ext)
85+
) {
86+
return `File must have one of these extensions: ${permittedExtensions.join(', ')}`;
87+
}
88+
}
89+
return null;
90+
};
91+
92+
useEffect(() => {
93+
if (isOpen) {
94+
loadFiles();
95+
setFileName(defaultName);
96+
const parts = defaultName.split('.');
97+
if (parts.length > 1) {
98+
setFileExtension(parts.pop());
99+
setFileName(parts.join('.'));
100+
}
101+
}
102+
}, [isOpen, defaultName, currentLocation]);
103+
104+
const handleNewFile = async () => {
105+
const name = prompt('Enter file name:');
106+
if (!name) return;
107+
108+
try {
109+
await createFile(name, '');
110+
await loadFiles();
111+
setShowNewMenu(false);
112+
} catch (error) {
113+
console.error('Failed to create file:', error);
114+
}
115+
};
116+
117+
const handleSelect = () => {
118+
if (mode === 'save') {
119+
const fullName =
120+
showExtension && fileExtension
121+
? `${fileName}.${fileExtension}`
122+
: fileName;
123+
const error = validateFileName(fullName);
124+
if (error) {
125+
alert(error);
126+
return;
127+
}
128+
onSelect({ name: fullName, isNew: true });
129+
}
130+
};
131+
132+
const handleLocationChange = (location) => {
133+
setCurrentLocation(location);
134+
setCurrentPath('/');
135+
};
136+
137+
if (!isOpen) return null;
138+
139+
return (
140+
<div className="absolute inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50">
141+
<div
142+
className={`rounded-lg shadow-2xl w-[800px] h-[600px] ${theme.app.bg} border ${theme.app.border} flex flex-col`}
143+
>
144+
{/* Header */}
145+
<div className="flex justify-between items-center p-4 border-b">
146+
<h2 className={`text-xl font-bold ${theme.app.text}`}>
147+
{mode === 'save' ? 'Save File' : 'Open File'}
148+
</h2>
149+
<button
150+
onClick={onClose}
151+
className={`p-1 rounded-full ${theme.app.text_subtle} ${theme.app.button_subtle_hover}`}
152+
>
153+
<XMarkIcon className="h-6 w-6" />
154+
</button>
155+
</div>
156+
157+
{/* Address Bar */}
158+
<div
159+
className={`px-4 py-2 border-b ${theme.app.border} flex items-center gap-2`}
160+
>
161+
<FolderIcon className="h-4 w-4" />
162+
<input
163+
type="text"
164+
value={currentPath}
165+
onChange={(e) => setCurrentPath(e.target.value)}
166+
className={`flex-1 px-2 py-1 rounded border ${theme.app.input} text-sm`}
167+
placeholder="/"
168+
/>
169+
</div>
170+
171+
<div className="flex flex-1 overflow-hidden">
172+
{/* Sidebar */}
173+
<div className={`w-48 border-r ${theme.app.border} p-2`}>
174+
<div className="space-y-1">
175+
<button
176+
onClick={() => handleLocationChange('local')}
177+
className={`w-full flex items-center gap-2 px-3 py-2 rounded text-left ${
178+
currentLocation === 'local'
179+
? theme.app.button
180+
: theme.app.button_subtle_hover
181+
}`}
182+
>
183+
<ComputerDesktopIcon className="h-4 w-4" />
184+
Local Files
185+
</button>
186+
{authStatus.connected && (
187+
<button
188+
onClick={() => handleLocationChange('drive')}
189+
className={`w-full flex items-center gap-2 px-3 py-2 rounded text-left ${
190+
currentLocation === 'drive'
191+
? theme.app.button
192+
: theme.app.button_subtle_hover
193+
}`}
194+
>
195+
<CloudIcon className="h-4 w-4" />
196+
Google Drive
197+
</button>
198+
)}
199+
</div>
200+
</div>
201+
202+
{/* Main Content */}
203+
<div className="flex-1 flex flex-col">
204+
{/* File List */}
205+
<div className="flex-1 p-4 overflow-y-auto">
206+
{isLoading ? (
207+
<div className="flex flex-col items-center justify-center h-full">
208+
<div className="text-4xl mb-2"></div>
209+
<p>Loading files...</p>
210+
</div>
211+
) : currentLocation === 'drive' && !authStatus.connected ? (
212+
<div className="flex flex-col items-center justify-center h-full text-center">
213+
<div className="text-6xl mb-4">🔐</div>
214+
<h3 className="text-lg font-semibold mb-2">
215+
Connect to Google Drive
216+
</h3>
217+
<p className="text-gray-500 mb-4">
218+
Sign in to access your files
219+
</p>
220+
<button
221+
onClick={() =>
222+
(window.location.href = '/api/auth/google/login')
223+
}
224+
className={`px-4 py-2 rounded ${theme.app.button}`}
225+
>
226+
🔗 Connect
227+
</button>
228+
</div>
229+
) : currentLocation === 'local' ? (
230+
<div className="flex flex-col items-center justify-center h-full text-center">
231+
<div className="text-6xl mb-4">💻</div>
232+
<h3 className="text-lg font-semibold mb-2">Local Files</h3>
233+
<p className="text-gray-500">
234+
Local file access not available in web version
235+
</p>
236+
</div>
237+
) : files.length === 0 ? (
238+
<div className="flex flex-col items-center justify-center h-full text-center">
239+
<div className="text-6xl mb-4">📁</div>
240+
<h3 className="text-lg font-semibold mb-2">
241+
No files to show
242+
</h3>
243+
<p className="text-gray-500">
244+
Create your first file to get started
245+
</p>
246+
</div>
247+
) : (
248+
<div className="grid grid-cols-1 gap-1">
249+
{files.map((file) => (
250+
<div
251+
key={file.id}
252+
onClick={() =>
253+
mode === 'open'
254+
? onSelect(file)
255+
: setFileName(file.name.split('.')[0])
256+
}
257+
className={`p-3 rounded cursor-pointer flex items-center gap-3 ${theme.app.button_subtle_hover}`}
258+
>
259+
<div className="text-2xl">📄</div>
260+
<div className="flex-1">
261+
<div className="font-medium">{file.name}</div>
262+
<div className="text-xs text-gray-500">
263+
{new Date(file.lastModified).toLocaleDateString()}
264+
</div>
265+
</div>
266+
</div>
267+
))}
268+
</div>
269+
)}
270+
</div>
271+
272+
{/* Bottom Bar */}
273+
<div className={`border-t ${theme.app.border} p-4`}>
274+
<div className="flex items-center gap-2 mb-3">
275+
<label className="text-sm font-medium">File name:</label>
276+
<input
277+
type="text"
278+
value={fileName}
279+
onChange={(e) => setFileName(e.target.value)}
280+
className={`flex-1 px-3 py-2 rounded border ${theme.app.input}`}
281+
placeholder="Enter filename..."
282+
/>
283+
{showExtension && (
284+
<>
285+
<span>.</span>
286+
<input
287+
type="text"
288+
value={fileExtension}
289+
onChange={(e) => setFileExtension(e.target.value)}
290+
className={`w-20 px-2 py-2 rounded border ${theme.app.input}`}
291+
placeholder="ext"
292+
/>
293+
</>
294+
)}
295+
</div>
296+
297+
{permittedExtensions.length > 0 && (
298+
<div className="text-xs text-gray-500 mb-3">
299+
Allowed extensions: {permittedExtensions.join(', ')}
300+
</div>
301+
)}
302+
303+
<div className="flex justify-end gap-2">
304+
<button
305+
onClick={onClose}
306+
className={`px-4 py-2 rounded ${theme.app.button_subtle_hover}`}
307+
>
308+
Cancel
309+
</button>
310+
{mode === 'save' && (
311+
<button
312+
onClick={handleSelect}
313+
disabled={!fileName.trim()}
314+
className={`px-4 py-2 rounded ${theme.app.button} disabled:opacity-50`}
315+
>
316+
Save
317+
</button>
318+
)}
319+
{mode === 'open' && currentLocation === 'drive' && (
320+
<button
321+
onClick={handleNewFile}
322+
className={`px-4 py-2 rounded ${theme.app.button}`}
323+
>
324+
➕ New File
325+
</button>
326+
)}
327+
</div>
328+
</div>
329+
</div>
330+
</div>
331+
</div>
332+
</div>
333+
);
334+
};
335+
336+
export const useFileDialog = () => {
337+
const [isOpen, setIsOpen] = useState(false);
338+
const [mode, setMode] = useState('open');
339+
const [defaultName, setDefaultName] = useState('');
340+
const [permittedExtensions, setPermittedExtensions] = useState([]);
341+
const [showExtension, setShowExtension] = useState(true);
342+
const [onSelectCallback, setOnSelectCallback] = useState(null);
343+
344+
const openDialog = (
345+
dialogMode = 'open',
346+
fileName = '',
347+
onSelect,
348+
options = {},
349+
) => {
350+
setMode(dialogMode);
351+
setDefaultName(fileName);
352+
setPermittedExtensions(options.permittedExtensions || []);
353+
setShowExtension(options.showExtension !== false);
354+
setOnSelectCallback(() => onSelect);
355+
setIsOpen(true);
356+
};
357+
358+
const closeDialog = () => {
359+
setIsOpen(false);
360+
setOnSelectCallback(null);
361+
};
362+
363+
const handleSelect = (file) => {
364+
if (onSelectCallback) {
365+
onSelectCallback(file);
366+
}
367+
closeDialog();
368+
};
369+
370+
const FileDialogComponent = () => (
371+
<FileDialog
372+
isOpen={isOpen}
373+
onClose={closeDialog}
374+
onSelect={handleSelect}
375+
mode={mode}
376+
defaultName={defaultName}
377+
permittedExtensions={permittedExtensions}
378+
showExtension={showExtension}
379+
/>
380+
);
381+
382+
return {
383+
openDialog,
384+
closeDialog,
385+
FileDialogComponent,
386+
};
387+
};

0 commit comments

Comments
 (0)