From 044791d4966bb90b64d818b1b54b2cc9247bcbd9 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Fri, 20 Feb 2026 17:55:07 +0100 Subject: [PATCH] filebrowser ole :) --- packages/ui/shared/src/ui/schemas.ts | 4 - packages/ui/src/components/LightboxText.tsx | 192 ++++++++++ .../src/components/admin/StorageManager.tsx | 12 +- .../ui/src/components/feed/MobileFeed.tsx | 7 +- .../widgets/WidgetPropertiesForm.tsx | 155 +++++++- packages/ui/src/lib/registerWidgets.ts | 58 ++- .../src/modules/pages/FileBrowserWidget.tsx | 359 +++++++++++------- 7 files changed, 614 insertions(+), 173 deletions(-) create mode 100644 packages/ui/src/components/LightboxText.tsx diff --git a/packages/ui/shared/src/ui/schemas.ts b/packages/ui/shared/src/ui/schemas.ts index bfa139ed..26995fdc 100644 --- a/packages/ui/shared/src/ui/schemas.ts +++ b/packages/ui/shared/src/ui/schemas.ts @@ -167,10 +167,6 @@ export const FileBrowserWidgetSchema = z.object({ viewMode: z.enum(['list', 'thumbs']).default('list'), sortBy: z.enum(['name', 'ext', 'date', 'type']).default('name'), showToolbar: z.boolean().default(true), - canChangeMount: z.boolean().default(true), - allowFileViewer: z.boolean().default(true), - allowLightbox: z.boolean().default(true), - allowDownload: z.boolean().default(true), variables: WidgetVariablesSchema, }); diff --git a/packages/ui/src/components/LightboxText.tsx b/packages/ui/src/components/LightboxText.tsx new file mode 100644 index 00000000..4ebbcc48 --- /dev/null +++ b/packages/ui/src/components/LightboxText.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { X, Copy, Check, Download, FileCode, FileText } from 'lucide-react'; + +interface LightboxTextProps { + isOpen: boolean; + onClose: () => void; + /** URL to fetch text content from */ + url: string; + /** File name for display and language detection */ + fileName: string; +} + +// Extension → language label for display +const EXT_LANG: Record = { + ts: 'TypeScript', tsx: 'TypeScript (JSX)', js: 'JavaScript', jsx: 'JavaScript (JSX)', + py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java', + c: 'C', cpp: 'C++', h: 'C Header', hpp: 'C++ Header', cs: 'C#', + swift: 'Swift', kt: 'Kotlin', lua: 'Lua', php: 'PHP', r: 'R', + sh: 'Shell', bash: 'Bash', zsh: 'Zsh', ps1: 'PowerShell', bat: 'Batch', cmd: 'Batch', + sql: 'SQL', html: 'HTML', htm: 'HTML', css: 'CSS', + scss: 'SCSS', sass: 'Sass', less: 'Less', + json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML', xml: 'XML', + vue: 'Vue', svelte: 'Svelte', + md: 'Markdown', txt: 'Text', log: 'Log', csv: 'CSV', tsv: 'TSV', + tex: 'LaTeX', ini: 'INI', cfg: 'Config', conf: 'Config', + dockerfile: 'Dockerfile', makefile: 'Makefile', +}; + +function getLanguage(fileName: string): string { + const lower = fileName.toLowerCase(); + // Handle extensionless names + if (lower === 'dockerfile') return 'Dockerfile'; + if (lower === 'makefile') return 'Makefile'; + const dot = lower.lastIndexOf('.'); + if (dot < 0) return 'Text'; + const ext = lower.slice(dot + 1); + return EXT_LANG[ext] || 'Text'; +} + +export default function LightboxText({ isOpen, onClose, url, fileName }: LightboxTextProps) { + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!isOpen || !url) return; + setLoading(true); + setError(null); + setContent(null); + fetch(url) + .then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.text(); + }) + .then(setContent) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, [isOpen, url]); + + useEffect(() => { + if (!isOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [isOpen, onClose]); + + const handleCopy = async () => { + if (!content) return; + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const lineCount = content ? content.split('\n').length : 0; + const language = getLanguage(fileName); + const isCode = language !== 'Text' && language !== 'Log'; + + if (!isOpen) return null; + + return createPortal( +
+
e.stopPropagation()} + style={{ + background: '#1e1e2e', color: '#cdd6f4', borderRadius: 10, + width: '90vw', maxWidth: 900, maxHeight: '90vh', + display: 'flex', flexDirection: 'column', + boxShadow: '0 16px 48px rgba(0,0,0,0.5)', + border: '1px solid #313244', + overflow: 'hidden', + }} + > + {/* Header */} +
+ {isCode ? : } + + {fileName} + + + {language}{lineCount > 0 ? ` · ${lineCount} lines` : ''} + + + + + + +
+ + {/* Content */} +
+ {loading && ( +
+ Loading… +
+ )} + {error && ( +
+ Failed to load: {error} +
+ )} + {content !== null && ( +
+ {/* Line numbers */} +
+ {content.split('\n').map((_, i) => ( +
{i + 1}
+ ))} +
+ {/* Code */} +
+                                {content}
+                            
+
+ )} +
+
+
, + document.body + ); +} diff --git a/packages/ui/src/components/admin/StorageManager.tsx b/packages/ui/src/components/admin/StorageManager.tsx index 7008451c..53cbcee7 100644 --- a/packages/ui/src/components/admin/StorageManager.tsx +++ b/packages/ui/src/components/admin/StorageManager.tsx @@ -7,7 +7,7 @@ import { Separator } from "@/components/ui/separator"; export default function StorageManager() { // Default to 'root' mount, but we could allow selecting mounts if there are multiple. // For now assuming 'root' is the main VFS. - const [mount, setMount] = useState("root"); + const [mount, setMount] = useState("home"); const [currentPath, setCurrentPath] = useState("/"); const [selectedPath, setSelectedPath] = useState(null); @@ -23,14 +23,14 @@ export default function StorageManager() {

File Browser

- {/* - FileBrowserWidget now accepts path/onPathChange. - We pass variables={{}} to satisfy the interface if validation is strict, - though we made it optional in our fix (or will). - */} { + setMount(m); + setCurrentPath('/'); + setSelectedPath(null); + }} onPathChange={(p) => { setCurrentPath(p); setSelectedPath(null); // Clear selection when navigating diff --git a/packages/ui/src/components/feed/MobileFeed.tsx b/packages/ui/src/components/feed/MobileFeed.tsx index b9af67c0..9c945912 100644 --- a/packages/ui/src/components/feed/MobileFeed.tsx +++ b/packages/ui/src/components/feed/MobileFeed.tsx @@ -1,14 +1,13 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { FeedPost } from '@/lib/db'; +import React, { useEffect, useRef } from 'react'; import { FeedCard } from './FeedCard'; import { useAuth } from '@/hooks/useAuth'; -import * as db from '@/lib/db'; + import { Loader2 } from 'lucide-react'; import { useInView } from 'react-intersection-observer'; -import { useProfiles } from '@/contexts/ProfilesContext'; import { useFeedData, FeedSortOption } from '@/hooks/useFeedData'; import { useFeedCache } from '@/contexts/FeedCacheContext'; import { useOrganization } from '@/contexts/OrganizationContext'; +import { FeedPost } from '@/modules/posts/client-posts'; interface MobileFeedProps { source?: 'home' | 'collection' | 'tag' | 'user'; diff --git a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx index 265a3974..920d6433 100644 --- a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx +++ b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx @@ -10,13 +10,14 @@ import { ImagePickerDialog } from './ImagePickerDialog'; import { PagePickerDialog } from '@/modules/pages/PagePickerDialog'; -import { Image as ImageIcon, Maximize2, FileText, Sparkles } from 'lucide-react'; +import { Image as ImageIcon, Maximize2, FileText, Sparkles, FolderOpen } from 'lucide-react'; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import MarkdownEditor from '@/components/MarkdownEditorEx'; import { TailwindClassPicker } from './TailwindClassPicker'; import { TabsPropertyEditor } from './TabsPropertyEditor'; import { HtmlGeneratorWizard } from './HtmlGeneratorWizard'; +import { FileBrowserWidget } from '@/modules/pages/FileBrowserWidget'; export interface WidgetPropertiesFormProps { widgetDefinition: WidgetDefinition; @@ -53,6 +54,12 @@ export const WidgetPropertiesForm: React.FC = ({ const [activeMarkdownField, setActiveMarkdownField] = useState(null); const [htmlWizardOpen, setHtmlWizardOpen] = useState(false); const [activeHtmlField, setActiveHtmlField] = useState(null); + const [vfsPickerOpen, setVfsPickerOpen] = useState(false); + const [vfsPickerField, setVfsPickerField] = useState(null); + const [vfsPickerMountKey, setVfsPickerMountKey] = useState('mount'); + const [vfsPickerPathKey, setVfsPickerPathKey] = useState('path'); + const [vfsBrowseMount, setVfsBrowseMount] = useState('home'); + const [vfsBrowsePath, setVfsBrowsePath] = useState('/'); // Sync with prop changes (e.g. selection change) useEffect(() => { @@ -140,6 +147,52 @@ export const WidgetPropertiesForm: React.FC = ({
); + case 'selectWithText': { + const isPreset = config.options?.some((o: any) => o.value === value); + return ( +
+ + + {!isPreset && ( + updateSetting(key, e.target.value)} + className="w-full h-8 text-sm font-mono" + placeholder={config.default || 'Enter custom value…'} + /> + )} + {config.description && ( +

+ {config.description} +

+ )} +
+ ); + } + case 'boolean': return (
@@ -331,6 +384,60 @@ export const WidgetPropertiesForm: React.FC = ({
); + case 'vfsPicker': { + // Compound picker for mount + path — editable text field + browse dialog + const mountKey = config.mountKey || 'mount'; + const pathKey = config.pathKey || 'path'; + const currentMount = settings[mountKey] || config.defaultMount || 'home'; + const currentPickerPath = settings[pathKey] || '/'; + const displayValue = `${currentMount}:${currentPickerPath}`; + return ( +
+ +
+ { + const val = e.target.value; + const colonIdx = val.indexOf(':'); + if (colonIdx > 0) { + updateSetting(mountKey, val.slice(0, colonIdx)); + updateSetting(pathKey, val.slice(colonIdx + 1) || '/'); + } else { + updateSetting(pathKey, val || '/'); + } + }} + className="flex-1 font-mono text-xs h-8" + placeholder="mount:/path" + /> + +
+ {config.description && ( +

+ {config.description} +

+ )} +
+ ); + } + default: return null; } @@ -513,6 +620,52 @@ export const WidgetPropertiesForm: React.FC = ({ pageContext={pageContext} initialPrompt={activeHtmlField ? settings[activeHtmlField] : ''} /> + + {/* VFS Browse Dialog */} + { + if (!open) setVfsPickerOpen(false); + else { + setVfsBrowseMount(settings[vfsPickerMountKey] || 'home'); + setVfsBrowsePath(settings[vfsPickerPathKey] || '/'); + } + }}> + + + Browse Files +

+ {vfsBrowseMount}:{vfsBrowsePath} +

+
+
+ { + setVfsBrowseMount(m); + setVfsBrowsePath('/'); + }} + onPathChange={(p: string) => setVfsBrowsePath(p)} + viewMode="list" + mode="simple" + showToolbar={true} + glob="*.*" + sortBy="name" + /> +
+
+ + +
+
+
); }; diff --git a/packages/ui/src/lib/registerWidgets.ts b/packages/ui/src/lib/registerWidgets.ts index ffb8c2d4..e0d49c14 100644 --- a/packages/ui/src/lib/registerWidgets.ts +++ b/packages/ui/src/lib/registerWidgets.ts @@ -500,25 +500,31 @@ export function registerAllWidgets() { viewMode: 'list', sortBy: 'name', showToolbar: true, + canChangeMount: true, + allowFileViewer: true, + allowLightbox: true, + allowDownload: true, variables: {} }, configSchema: { - mount: { - type: 'text', - label: 'Mount', - description: 'VFS mount name from config/vfs.json', - default: 'test' - }, - path: { - type: 'text', - label: 'Initial Path', - description: 'Starting directory path (relative to mount root)', - default: '/' + mountAndPath: { + type: 'vfsPicker', + label: 'Mount & Initial Path', + description: 'Browse to select the mount and starting directory', + mountKey: 'mount', + pathKey: 'path', + defaultMount: 'home', }, glob: { - type: 'text', - label: 'Glob Pattern', - description: 'Filter files by glob pattern (e.g. *.jpg, **/*.png)', + type: 'selectWithText', + label: 'File Filter', + description: 'Filter which files are shown', + options: [ + { value: '*.*', label: 'All Files' }, + { value: '*.{jpg,jpeg,png,gif,webp,svg,bmp,avif,mp4,webm,mov,avi,mkv}', label: 'Media (Images & Video)' }, + { value: '*.{jpg,jpeg,png,gif,webp,svg,bmp,avif}', label: 'Images Only' }, + { value: '*.{mp4,webm,mov,avi,mkv,flv}', label: 'Videos Only' }, + ], default: '*.*' }, mode: { @@ -558,6 +564,30 @@ export function registerAllWidgets() { label: 'Show Toolbar', description: 'Show navigation toolbar with breadcrumbs, sort, and view toggle', default: true + }, + canChangeMount: { + type: 'boolean', + label: 'Allow Mount Switching', + description: 'Let users switch between VFS mounts', + default: true + }, + allowLightbox: { + type: 'boolean', + label: 'Allow Image/Video Lightbox', + description: 'Open images and videos in a fullscreen lightbox', + default: true + }, + allowFileViewer: { + type: 'boolean', + label: 'Allow Text File Viewer', + description: 'Open text/code files in the built-in viewer', + default: true + }, + allowDownload: { + type: 'boolean', + label: 'Allow Download', + description: 'Show download button for files', + default: true } }, minSize: { width: 300, height: 300 }, diff --git a/packages/ui/src/modules/pages/FileBrowserWidget.tsx b/packages/ui/src/modules/pages/FileBrowserWidget.tsx index dd0f8707..7b35f673 100644 --- a/packages/ui/src/modules/pages/FileBrowserWidget.tsx +++ b/packages/ui/src/modules/pages/FileBrowserWidget.tsx @@ -1,20 +1,27 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { + DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem +} from '@/components/ui/dropdown-menu'; import { Folder, File, ArrowUp, List, LayoutGrid, ArrowUpDown, Clock, FileType, Type, - ChevronRight, Info, Loader2, Download, ExternalLink, X, ChevronLeft, ZoomIn, ZoomOut, + ChevronRight, ChevronDown, Info, Loader2, Download, ExternalLink, ZoomIn, ZoomOut, Image, Film, Music, FileCode, FileText as FileTextIcon, - Archive, FileSpreadsheet, Presentation + Archive, FileSpreadsheet, Presentation, HardDrive, Home } from 'lucide-react'; import type { FileBrowserWidgetProps } from '@polymech/shared'; import { useAuth } from '@/hooks/useAuth'; import ResponsiveImage from '@/components/ResponsiveImage'; +import ImageLightbox from '@/components/ImageLightbox'; +import LightboxText from '@/components/LightboxText'; export interface FileBrowserWidgetExtendedProps extends Omit { variables?: any; // Controlled mode (optional) path?: string; onPathChange?: (path: string) => void; + onMountChange?: (mount: string) => void; onSelect?: (path: string | null) => void; } @@ -125,19 +132,29 @@ function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] { return [...dirs, ...files]; } +// ── URL helper ─────────────────────────────────────────────────── + +/** Build a VFS API URL. Always includes mount segment — resolveMount handles 'home'. */ +function vfsUrl(op: string, mount: string, subpath?: string): string { + const clean = subpath?.replace(/^\/+/, ''); + return clean + ? `/api/vfs/${op}/${encodeURIComponent(mount)}/${clean}` + : `/api/vfs/${op}/${encodeURIComponent(mount)}`; +} + // ── Thumbnail helper ───────────────────────────────────────────── -function ThumbPreview({ node, mount, height = 64, tokenParam = '' }: { node: INode; mount: string; height?: number; tokenParam?: string }) { +function ThumbPreview({ node, mount, tokenParam = '' }: { node: INode; mount: string; tokenParam?: string }) { const cat = getMimeCategory(node); - const baseUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path.replace(/^\/+/, '')}`; - const fileUrl = tokenParam ? `${baseUrl}?${tokenParam}` : baseUrl; + const fileUrl = vfsUrl('get', mount, node.path); + const fullUrl = tokenParam ? `${fileUrl}?${tokenParam}` : fileUrl; if (cat === 'image') { - return ; + return ; } if (cat === 'video') { return ( -
-