groove-dev 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@groove-dev/daemon/src/api.js +122 -1
- package/node_modules/@groove-dev/daemon/src/mimetypes.js +43 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-Dxg9hdf3.js → index-BQSznoq0.js} +36 -36
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/src/components/FileTree.jsx +322 -23
- package/node_modules/@groove-dev/gui/src/components/MediaViewer.jsx +104 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +131 -2
- package/node_modules/@groove-dev/gui/src/views/FileEditor.jsx +11 -4
- package/package.json +1 -1
- package/packages/daemon/src/api.js +122 -1
- package/packages/daemon/src/mimetypes.js +43 -0
- package/packages/gui/dist/assets/{index-Dxg9hdf3.js → index-BQSznoq0.js} +36 -36
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/src/components/FileTree.jsx +322 -23
- package/packages/gui/src/components/MediaViewer.jsx +104 -0
- package/packages/gui/src/stores/groove.js +131 -2
- package/packages/gui/src/views/FileEditor.jsx +11 -4
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>GROOVE</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-BQSznoq0.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-Gfb8Zxy9.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GROOVE GUI — File Tree (expandable directory browser)
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
5
5
|
import { useGrooveStore } from '../stores/groove';
|
|
6
6
|
|
|
7
7
|
const FILE_COLORS = {
|
|
@@ -15,14 +15,14 @@ const FILE_COLORS = {
|
|
|
15
15
|
rust: '#e06c75',
|
|
16
16
|
go: '#33afbc',
|
|
17
17
|
shell: '#4ae168',
|
|
18
|
-
yaml: '#e06c75',
|
|
19
18
|
text: '#abb2bf',
|
|
20
19
|
};
|
|
21
20
|
|
|
22
|
-
function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileClick }) {
|
|
21
|
+
function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileClick, onContextMenu, renamingPath, renameValue, onRenameChange, onRenameSubmit, onRenameCancel }) {
|
|
23
22
|
const isDir = entry.type === 'dir';
|
|
24
23
|
const isExpanded = expandedDirs.has(entry.path);
|
|
25
24
|
const isActive = !isDir && entry.path === activeFile;
|
|
25
|
+
const isRenaming = renamingPath === entry.path;
|
|
26
26
|
const treeCache = useGrooveStore((s) => s.editorTreeCache);
|
|
27
27
|
const children = treeCache[entry.path] || [];
|
|
28
28
|
|
|
@@ -30,6 +30,7 @@ function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileC
|
|
|
30
30
|
<>
|
|
31
31
|
<div
|
|
32
32
|
onClick={() => isDir ? onToggleDir(entry.path, entry.hasChildren) : onFileClick(entry.path)}
|
|
33
|
+
onContextMenu={(e) => onContextMenu(e, entry)}
|
|
33
34
|
style={{
|
|
34
35
|
...styles.row,
|
|
35
36
|
paddingLeft: 12 + depth * 16,
|
|
@@ -42,14 +43,29 @@ function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileC
|
|
|
42
43
|
) : (
|
|
43
44
|
<span style={{ ...styles.fileDot, background: FILE_COLORS[entry.language] || FILE_COLORS.text }} />
|
|
44
45
|
)}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
{isRenaming ? (
|
|
47
|
+
<input
|
|
48
|
+
autoFocus
|
|
49
|
+
value={renameValue}
|
|
50
|
+
onChange={(e) => onRenameChange(e.target.value)}
|
|
51
|
+
onKeyDown={(e) => {
|
|
52
|
+
if (e.key === 'Enter') onRenameSubmit();
|
|
53
|
+
if (e.key === 'Escape') onRenameCancel();
|
|
54
|
+
}}
|
|
55
|
+
onBlur={onRenameCancel}
|
|
56
|
+
onClick={(e) => e.stopPropagation()}
|
|
57
|
+
style={styles.renameInput}
|
|
58
|
+
/>
|
|
59
|
+
) : (
|
|
60
|
+
<span style={{
|
|
61
|
+
...styles.name,
|
|
62
|
+
color: isDir ? 'var(--text-primary)' : (isActive ? 'var(--text-bright)' : 'var(--text-primary)'),
|
|
63
|
+
fontWeight: isDir ? 600 : 400,
|
|
64
|
+
}}>
|
|
65
|
+
{entry.name}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
{!isDir && !isRenaming && entry.size > 0 && (
|
|
53
69
|
<span style={styles.size}>{formatSize(entry.size)}</span>
|
|
54
70
|
)}
|
|
55
71
|
</div>
|
|
@@ -62,6 +78,12 @@ function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileC
|
|
|
62
78
|
expandedDirs={expandedDirs}
|
|
63
79
|
onToggleDir={onToggleDir}
|
|
64
80
|
onFileClick={onFileClick}
|
|
81
|
+
onContextMenu={onContextMenu}
|
|
82
|
+
renamingPath={renamingPath}
|
|
83
|
+
renameValue={renameValue}
|
|
84
|
+
onRenameChange={onRenameChange}
|
|
85
|
+
onRenameSubmit={onRenameSubmit}
|
|
86
|
+
onRenameCancel={onRenameCancel}
|
|
65
87
|
/>
|
|
66
88
|
))}
|
|
67
89
|
</>
|
|
@@ -74,15 +96,97 @@ function formatSize(bytes) {
|
|
|
74
96
|
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
75
97
|
}
|
|
76
98
|
|
|
99
|
+
// --- Context Menu ---
|
|
100
|
+
function ContextMenu({ x, y, entry, onClose, onAction }) {
|
|
101
|
+
const ref = useRef(null);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const handle = (e) => {
|
|
105
|
+
if (ref.current && !ref.current.contains(e.target)) onClose();
|
|
106
|
+
};
|
|
107
|
+
document.addEventListener('mousedown', handle);
|
|
108
|
+
return () => document.removeEventListener('mousedown', handle);
|
|
109
|
+
}, [onClose]);
|
|
110
|
+
|
|
111
|
+
const isDir = entry?.type === 'dir';
|
|
112
|
+
|
|
113
|
+
const items = [
|
|
114
|
+
...(isDir ? [
|
|
115
|
+
{ label: 'New File Here', action: 'newFileIn' },
|
|
116
|
+
{ label: 'New Folder Here', action: 'newDirIn' },
|
|
117
|
+
{ sep: true },
|
|
118
|
+
] : []),
|
|
119
|
+
{ label: 'Rename', action: 'rename' },
|
|
120
|
+
{ label: 'Delete', action: 'delete', danger: true },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div ref={ref} style={{ ...styles.contextMenu, left: x, top: y }}>
|
|
125
|
+
{items.map((item, i) =>
|
|
126
|
+
item.sep ? (
|
|
127
|
+
<div key={i} style={styles.contextSep} />
|
|
128
|
+
) : (
|
|
129
|
+
<div
|
|
130
|
+
key={item.action}
|
|
131
|
+
onClick={() => { onAction(item.action, entry); onClose(); }}
|
|
132
|
+
style={{
|
|
133
|
+
...styles.contextItem,
|
|
134
|
+
color: item.danger ? 'var(--red)' : 'var(--text-primary)',
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{item.label}
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Inline New-Item Input ---
|
|
146
|
+
function InlineInput({ placeholder, onSubmit, onCancel, depth }) {
|
|
147
|
+
const [value, setValue] = useState('');
|
|
148
|
+
return (
|
|
149
|
+
<div style={{ ...styles.row, paddingLeft: 12 + (depth || 0) * 16 }}>
|
|
150
|
+
<input
|
|
151
|
+
autoFocus
|
|
152
|
+
placeholder={placeholder}
|
|
153
|
+
value={value}
|
|
154
|
+
onChange={(e) => setValue(e.target.value)}
|
|
155
|
+
onKeyDown={(e) => {
|
|
156
|
+
if (e.key === 'Enter' && value.trim()) onSubmit(value.trim());
|
|
157
|
+
if (e.key === 'Escape') onCancel();
|
|
158
|
+
}}
|
|
159
|
+
onBlur={onCancel}
|
|
160
|
+
style={styles.renameInput}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Main FileTree ---
|
|
77
167
|
export default function FileTree() {
|
|
78
168
|
const treeCache = useGrooveStore((s) => s.editorTreeCache);
|
|
79
169
|
const activeFile = useGrooveStore((s) => s.editorActiveFile);
|
|
80
170
|
const fetchTreeDir = useGrooveStore((s) => s.fetchTreeDir);
|
|
81
171
|
const openFile = useGrooveStore((s) => s.openFile);
|
|
172
|
+
const createFile = useGrooveStore((s) => s.createFile);
|
|
173
|
+
const createDir = useGrooveStore((s) => s.createDir);
|
|
174
|
+
const deleteFile = useGrooveStore((s) => s.deleteFile);
|
|
175
|
+
const renameFile = useGrooveStore((s) => s.renameFile);
|
|
82
176
|
|
|
83
177
|
const [expandedDirs, setExpandedDirs] = useState(new Set(['']));
|
|
84
178
|
const [filter, setFilter] = useState('');
|
|
85
179
|
|
|
180
|
+
// Context menu state
|
|
181
|
+
const [contextMenu, setContextMenu] = useState(null); // { x, y, entry }
|
|
182
|
+
|
|
183
|
+
// Inline creation state
|
|
184
|
+
const [creating, setCreating] = useState(null); // { type: 'file'|'dir', parentPath: '' }
|
|
185
|
+
|
|
186
|
+
// Rename state
|
|
187
|
+
const [renamingPath, setRenamingPath] = useState(null);
|
|
188
|
+
const [renameValue, setRenameValue] = useState('');
|
|
189
|
+
|
|
86
190
|
// Fetch root on mount
|
|
87
191
|
useEffect(() => {
|
|
88
192
|
fetchTreeDir('');
|
|
@@ -103,15 +207,125 @@ export default function FileTree() {
|
|
|
103
207
|
});
|
|
104
208
|
}, [fetchTreeDir, treeCache]);
|
|
105
209
|
|
|
210
|
+
const onContextMenu = useCallback((e, entry) => {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
e.stopPropagation();
|
|
213
|
+
setContextMenu({ x: e.clientX, y: e.clientY, entry });
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
const handleContextAction = useCallback(async (action, entry) => {
|
|
217
|
+
const parentDir = entry.path.includes('/') ? entry.path.split('/').slice(0, -1).join('/') : '';
|
|
218
|
+
|
|
219
|
+
switch (action) {
|
|
220
|
+
case 'newFileIn':
|
|
221
|
+
setCreating({ type: 'file', parentPath: entry.path });
|
|
222
|
+
// Ensure dir is expanded
|
|
223
|
+
setExpandedDirs((prev) => {
|
|
224
|
+
const next = new Set(prev);
|
|
225
|
+
next.add(entry.path);
|
|
226
|
+
if (!treeCache[entry.path]) fetchTreeDir(entry.path);
|
|
227
|
+
return next;
|
|
228
|
+
});
|
|
229
|
+
break;
|
|
230
|
+
case 'newDirIn':
|
|
231
|
+
setCreating({ type: 'dir', parentPath: entry.path });
|
|
232
|
+
setExpandedDirs((prev) => {
|
|
233
|
+
const next = new Set(prev);
|
|
234
|
+
next.add(entry.path);
|
|
235
|
+
if (!treeCache[entry.path]) fetchTreeDir(entry.path);
|
|
236
|
+
return next;
|
|
237
|
+
});
|
|
238
|
+
break;
|
|
239
|
+
case 'rename':
|
|
240
|
+
setRenamingPath(entry.path);
|
|
241
|
+
setRenameValue(entry.name);
|
|
242
|
+
break;
|
|
243
|
+
case 'delete':
|
|
244
|
+
if (confirm(`Delete "${entry.name}"?`)) {
|
|
245
|
+
await deleteFile(entry.path);
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}, [deleteFile, fetchTreeDir, treeCache]);
|
|
250
|
+
|
|
251
|
+
const handleCreate = useCallback(async (name) => {
|
|
252
|
+
if (!creating) return;
|
|
253
|
+
const fullPath = creating.parentPath ? `${creating.parentPath}/${name}` : name;
|
|
254
|
+
const ok = creating.type === 'file'
|
|
255
|
+
? await createFile(fullPath)
|
|
256
|
+
: await createDir(fullPath);
|
|
257
|
+
setCreating(null);
|
|
258
|
+
if (ok && creating.type === 'file') openFile(fullPath);
|
|
259
|
+
}, [creating, createFile, createDir, openFile]);
|
|
260
|
+
|
|
261
|
+
const handleRenameSubmit = useCallback(async () => {
|
|
262
|
+
if (!renamingPath || !renameValue.trim()) { setRenamingPath(null); return; }
|
|
263
|
+
const parentDir = renamingPath.includes('/') ? renamingPath.split('/').slice(0, -1).join('/') : '';
|
|
264
|
+
const newPath = parentDir ? `${parentDir}/${renameValue.trim()}` : renameValue.trim();
|
|
265
|
+
if (newPath !== renamingPath) {
|
|
266
|
+
await renameFile(renamingPath, newPath);
|
|
267
|
+
}
|
|
268
|
+
setRenamingPath(null);
|
|
269
|
+
}, [renamingPath, renameValue, renameFile]);
|
|
270
|
+
|
|
271
|
+
// Handle root-level context menu (right-click on empty space)
|
|
272
|
+
const handleTreeContextMenu = useCallback((e) => {
|
|
273
|
+
// Only if click is on the tree background, not a node
|
|
274
|
+
if (e.target === e.currentTarget) {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
setContextMenu({ x: e.clientX, y: e.clientY, entry: { type: 'dir', path: '', name: 'root' } });
|
|
277
|
+
}
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
106
280
|
const rootEntries = treeCache[''] || [];
|
|
107
281
|
|
|
108
|
-
// Client-side filter
|
|
282
|
+
// Client-side filter — search all cached entries recursively
|
|
109
283
|
const filtered = filter
|
|
110
284
|
? rootEntries.filter((e) => e.name.toLowerCase().includes(filter.toLowerCase()))
|
|
111
285
|
: rootEntries;
|
|
112
286
|
|
|
287
|
+
// Calculate inline input depth based on parent
|
|
288
|
+
const creatingDepth = creating
|
|
289
|
+
? (creating.parentPath === '' ? 0 : creating.parentPath.split('/').length)
|
|
290
|
+
: 0;
|
|
291
|
+
|
|
113
292
|
return (
|
|
114
293
|
<div style={styles.container}>
|
|
294
|
+
{/* Toolbar */}
|
|
295
|
+
<div style={styles.toolbar}>
|
|
296
|
+
<span style={styles.toolbarTitle}>FILES</span>
|
|
297
|
+
<div style={styles.toolbarActions}>
|
|
298
|
+
<button
|
|
299
|
+
onClick={() => setCreating({ type: 'file', parentPath: '' })}
|
|
300
|
+
title="New File"
|
|
301
|
+
style={styles.toolbarBtn}
|
|
302
|
+
>
|
|
303
|
+
+f
|
|
304
|
+
</button>
|
|
305
|
+
<button
|
|
306
|
+
onClick={() => setCreating({ type: 'dir', parentPath: '' })}
|
|
307
|
+
title="New Folder"
|
|
308
|
+
style={styles.toolbarBtn}
|
|
309
|
+
>
|
|
310
|
+
+d
|
|
311
|
+
</button>
|
|
312
|
+
<button
|
|
313
|
+
onClick={() => setExpandedDirs(new Set(['']))}
|
|
314
|
+
title="Collapse All"
|
|
315
|
+
style={styles.toolbarBtn}
|
|
316
|
+
>
|
|
317
|
+
{'\u2191'}
|
|
318
|
+
</button>
|
|
319
|
+
<button
|
|
320
|
+
onClick={() => fetchTreeDir('')}
|
|
321
|
+
title="Refresh"
|
|
322
|
+
style={styles.toolbarBtn}
|
|
323
|
+
>
|
|
324
|
+
{'\u21BB'}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
115
329
|
{/* Search */}
|
|
116
330
|
<div style={styles.searchWrap}>
|
|
117
331
|
<input
|
|
@@ -124,24 +338,62 @@ export default function FileTree() {
|
|
|
124
338
|
</div>
|
|
125
339
|
|
|
126
340
|
{/* Tree */}
|
|
127
|
-
<div style={styles.tree}>
|
|
128
|
-
{filtered.length === 0 && (
|
|
341
|
+
<div style={styles.tree} onContextMenu={handleTreeContextMenu}>
|
|
342
|
+
{filtered.length === 0 && !creating && (
|
|
129
343
|
<div style={styles.empty}>
|
|
130
344
|
{rootEntries.length === 0 ? 'Loading...' : 'No matches'}
|
|
131
345
|
</div>
|
|
132
346
|
)}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
347
|
+
|
|
348
|
+
{/* Inline creation at root level */}
|
|
349
|
+
{creating && creating.parentPath === '' && (
|
|
350
|
+
<InlineInput
|
|
351
|
+
placeholder={creating.type === 'file' ? 'filename.ext' : 'folder-name'}
|
|
352
|
+
onSubmit={handleCreate}
|
|
353
|
+
onCancel={() => setCreating(null)}
|
|
137
354
|
depth={0}
|
|
138
|
-
activeFile={activeFile}
|
|
139
|
-
expandedDirs={expandedDirs}
|
|
140
|
-
onToggleDir={onToggleDir}
|
|
141
|
-
onFileClick={openFile}
|
|
142
355
|
/>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{filtered.map((entry) => (
|
|
359
|
+
<React.Fragment key={entry.path}>
|
|
360
|
+
<TreeNode
|
|
361
|
+
entry={entry}
|
|
362
|
+
depth={0}
|
|
363
|
+
activeFile={activeFile}
|
|
364
|
+
expandedDirs={expandedDirs}
|
|
365
|
+
onToggleDir={onToggleDir}
|
|
366
|
+
onFileClick={openFile}
|
|
367
|
+
onContextMenu={onContextMenu}
|
|
368
|
+
renamingPath={renamingPath}
|
|
369
|
+
renameValue={renameValue}
|
|
370
|
+
onRenameChange={setRenameValue}
|
|
371
|
+
onRenameSubmit={handleRenameSubmit}
|
|
372
|
+
onRenameCancel={() => setRenamingPath(null)}
|
|
373
|
+
/>
|
|
374
|
+
{/* Inline creation inside this dir */}
|
|
375
|
+
{creating && creating.parentPath === entry.path && expandedDirs.has(entry.path) && (
|
|
376
|
+
<InlineInput
|
|
377
|
+
placeholder={creating.type === 'file' ? 'filename.ext' : 'folder-name'}
|
|
378
|
+
onSubmit={handleCreate}
|
|
379
|
+
onCancel={() => setCreating(null)}
|
|
380
|
+
depth={1}
|
|
381
|
+
/>
|
|
382
|
+
)}
|
|
383
|
+
</React.Fragment>
|
|
143
384
|
))}
|
|
144
385
|
</div>
|
|
386
|
+
|
|
387
|
+
{/* Context Menu */}
|
|
388
|
+
{contextMenu && (
|
|
389
|
+
<ContextMenu
|
|
390
|
+
x={contextMenu.x}
|
|
391
|
+
y={contextMenu.y}
|
|
392
|
+
entry={contextMenu.entry}
|
|
393
|
+
onClose={() => setContextMenu(null)}
|
|
394
|
+
onAction={handleContextAction}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
145
397
|
</div>
|
|
146
398
|
);
|
|
147
399
|
}
|
|
@@ -150,9 +402,29 @@ const styles = {
|
|
|
150
402
|
container: {
|
|
151
403
|
display: 'flex', flexDirection: 'column',
|
|
152
404
|
height: '100%', background: 'var(--bg-chrome)',
|
|
405
|
+
position: 'relative',
|
|
406
|
+
},
|
|
407
|
+
toolbar: {
|
|
408
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
409
|
+
padding: '6px 10px',
|
|
410
|
+
borderBottom: '1px solid var(--border)',
|
|
411
|
+
},
|
|
412
|
+
toolbarTitle: {
|
|
413
|
+
fontSize: 10, fontWeight: 700, letterSpacing: 1,
|
|
414
|
+
color: 'var(--text-dim)', textTransform: 'uppercase',
|
|
415
|
+
},
|
|
416
|
+
toolbarActions: {
|
|
417
|
+
display: 'flex', gap: 2,
|
|
418
|
+
},
|
|
419
|
+
toolbarBtn: {
|
|
420
|
+
background: 'none', border: 'none',
|
|
421
|
+
color: 'var(--text-dim)', fontSize: 11, fontWeight: 600,
|
|
422
|
+
cursor: 'pointer', fontFamily: 'var(--font)',
|
|
423
|
+
padding: '2px 5px', borderRadius: 3,
|
|
424
|
+
lineHeight: 1,
|
|
153
425
|
},
|
|
154
426
|
searchWrap: {
|
|
155
|
-
padding: '
|
|
427
|
+
padding: '6px 8px',
|
|
156
428
|
borderBottom: '1px solid var(--border)',
|
|
157
429
|
},
|
|
158
430
|
searchInput: {
|
|
@@ -190,4 +462,31 @@ const styles = {
|
|
|
190
462
|
padding: 16, textAlign: 'center',
|
|
191
463
|
fontSize: 11, color: 'var(--text-dim)',
|
|
192
464
|
},
|
|
465
|
+
renameInput: {
|
|
466
|
+
flex: 1, padding: '2px 6px',
|
|
467
|
+
background: 'var(--bg-base)', border: '1px solid var(--accent)',
|
|
468
|
+
borderRadius: 2, color: 'var(--text-bright)',
|
|
469
|
+
fontSize: 12, fontFamily: 'var(--font)',
|
|
470
|
+
outline: 'none', minWidth: 0,
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
// Context menu
|
|
474
|
+
contextMenu: {
|
|
475
|
+
position: 'fixed', zIndex: 300,
|
|
476
|
+
background: 'var(--bg-surface)',
|
|
477
|
+
border: '1px solid var(--border)',
|
|
478
|
+
borderRadius: 4, padding: '4px 0',
|
|
479
|
+
minWidth: 160,
|
|
480
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
|
481
|
+
fontFamily: 'var(--font)',
|
|
482
|
+
},
|
|
483
|
+
contextItem: {
|
|
484
|
+
padding: '6px 14px', fontSize: 11,
|
|
485
|
+
cursor: 'pointer',
|
|
486
|
+
transition: 'background 0.08s',
|
|
487
|
+
},
|
|
488
|
+
contextSep: {
|
|
489
|
+
height: 1, background: 'var(--border)',
|
|
490
|
+
margin: '4px 0',
|
|
491
|
+
},
|
|
193
492
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// GROOVE GUI — Media Viewer (images + video)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif']);
|
|
7
|
+
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv', 'ogv']);
|
|
8
|
+
|
|
9
|
+
export function isMediaFile(path) {
|
|
10
|
+
const ext = path.split('.').pop()?.toLowerCase();
|
|
11
|
+
return IMAGE_EXTS.has(ext) || VIDEO_EXTS.has(ext);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isImageFile(path) {
|
|
15
|
+
const ext = path.split('.').pop()?.toLowerCase();
|
|
16
|
+
return IMAGE_EXTS.has(ext);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function MediaViewer({ path }) {
|
|
20
|
+
const ext = path.split('.').pop()?.toLowerCase();
|
|
21
|
+
const rawUrl = `/api/files/raw?path=${encodeURIComponent(path)}`;
|
|
22
|
+
const filename = path.split('/').pop();
|
|
23
|
+
const isImage = IMAGE_EXTS.has(ext);
|
|
24
|
+
const isVideo = VIDEO_EXTS.has(ext);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div style={styles.container}>
|
|
28
|
+
<div style={styles.header}>
|
|
29
|
+
<span style={styles.filename}>{filename}</span>
|
|
30
|
+
<span style={styles.badge}>{ext.toUpperCase()}</span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div style={styles.preview}>
|
|
34
|
+
{isImage && (
|
|
35
|
+
<img
|
|
36
|
+
src={rawUrl}
|
|
37
|
+
alt={filename}
|
|
38
|
+
style={styles.image}
|
|
39
|
+
draggable={false}
|
|
40
|
+
/>
|
|
41
|
+
)}
|
|
42
|
+
{isVideo && (
|
|
43
|
+
<video
|
|
44
|
+
src={rawUrl}
|
|
45
|
+
controls
|
|
46
|
+
style={styles.video}
|
|
47
|
+
>
|
|
48
|
+
Your browser does not support this video format.
|
|
49
|
+
</video>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div style={styles.footer}>
|
|
54
|
+
<a href={rawUrl} target="_blank" rel="noopener noreferrer" style={styles.link}>
|
|
55
|
+
Open in new tab
|
|
56
|
+
</a>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const styles = {
|
|
63
|
+
container: {
|
|
64
|
+
flex: 1, display: 'flex', flexDirection: 'column',
|
|
65
|
+
background: 'var(--bg-base)', overflow: 'hidden',
|
|
66
|
+
},
|
|
67
|
+
header: {
|
|
68
|
+
padding: '10px 16px',
|
|
69
|
+
borderBottom: '1px solid var(--border)',
|
|
70
|
+
display: 'flex', alignItems: 'center', gap: 8,
|
|
71
|
+
},
|
|
72
|
+
filename: {
|
|
73
|
+
fontSize: 12, color: 'var(--text-bright)', fontWeight: 500,
|
|
74
|
+
},
|
|
75
|
+
badge: {
|
|
76
|
+
fontSize: 9, fontWeight: 700, letterSpacing: 0.5,
|
|
77
|
+
color: 'var(--text-dim)', background: 'var(--bg-active)',
|
|
78
|
+
padding: '2px 6px', borderRadius: 3,
|
|
79
|
+
},
|
|
80
|
+
preview: {
|
|
81
|
+
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
82
|
+
overflow: 'auto', padding: 24,
|
|
83
|
+
background: 'repeating-conic-gradient(var(--bg-surface) 0% 25%, var(--bg-base) 0% 50%) 50% / 20px 20px',
|
|
84
|
+
},
|
|
85
|
+
image: {
|
|
86
|
+
maxWidth: '100%', maxHeight: '100%',
|
|
87
|
+
objectFit: 'contain', borderRadius: 4,
|
|
88
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
89
|
+
},
|
|
90
|
+
video: {
|
|
91
|
+
maxWidth: '100%', maxHeight: '100%',
|
|
92
|
+
borderRadius: 4, outline: 'none',
|
|
93
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
94
|
+
},
|
|
95
|
+
footer: {
|
|
96
|
+
padding: '8px 16px',
|
|
97
|
+
borderTop: '1px solid var(--border)',
|
|
98
|
+
display: 'flex', alignItems: 'center',
|
|
99
|
+
},
|
|
100
|
+
link: {
|
|
101
|
+
fontSize: 11, color: 'var(--accent)',
|
|
102
|
+
textDecoration: 'none', fontFamily: 'var(--font)',
|
|
103
|
+
},
|
|
104
|
+
};
|