Created
September 20, 2024 04:16
-
-
Save PatrickJS/9b97a3ffe00d5088a0ec2f8c81949c0b to your computer and use it in GitHub Desktop.
Basic Folder/File Recursive UI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { component$, useStore, useSignal, useTask$ } from '@builder.io/qwik'; | |
// Define the Node type | |
type Node = { | |
id: string; | |
name: string; | |
type: string; | |
tags: string[]; | |
children?: Node[]; | |
attributes?: Record<string, unknown>; | |
pending?: boolean; | |
}; | |
// Define the PendingOperation type | |
type PendingOperation = { | |
type: 'create' | 'update' | 'delete'; | |
node: Node; | |
path: string[]; | |
}; | |
// Sample data | |
const fileSystemData: Node = { | |
id: "root", | |
name: "Root", | |
type: "directory", | |
tags: ["root"], | |
children: [ | |
{ | |
id: "dir1", | |
name: "Work", | |
type: "directory", | |
tags: ["work"], | |
children: [ | |
{ | |
id: "dir1-1", | |
name: "Projects", | |
type: "directory", | |
tags: ["work", "projects"], | |
children: [ | |
{ | |
id: "dir1-1-1", | |
name: "Project A", | |
type: "directory", | |
tags: ["work", "project-a"], | |
children: [ | |
{ | |
id: "file1", | |
name: "Proposal.docx", | |
type: "file", | |
tags: ["document", "proposal"], | |
attributes: { size: 1024 } | |
}, | |
{ | |
id: "file2", | |
name: "Budget.xlsx", | |
type: "file", | |
tags: ["spreadsheet", "budget"], | |
attributes: { size: 2048 } | |
} | |
] | |
}, | |
{ | |
id: "dir1-1-2", | |
name: "Project B", | |
type: "directory", | |
tags: ["work", "project-b"], | |
children: [ | |
{ | |
id: "file3", | |
name: "Report.pdf", | |
type: "file", | |
tags: ["document", "report"], | |
attributes: { size: 3072 } | |
} | |
] | |
} | |
] | |
} | |
] | |
}, | |
{ | |
id: "dir2", | |
name: "Personal", | |
type: "directory", | |
tags: ["personal"], | |
children: [ | |
{ | |
id: "dir2-1", | |
name: "Photos", | |
type: "directory", | |
tags: ["personal", "photos"], | |
children: [ | |
{ | |
id: "file4", | |
name: "Vacation.jpg", | |
type: "file", | |
tags: ["image", "vacation"], | |
attributes: { size: 4096 } | |
} | |
] | |
}, | |
{ | |
id: "dir2-2", | |
name: "Documents", | |
type: "directory", | |
tags: ["personal", "documents"], | |
children: [ | |
{ | |
id: "file5", | |
name: "Resume.pdf", | |
type: "file", | |
tags: ["document", "resume"], | |
attributes: { size: 2560 } | |
} | |
] | |
} | |
] | |
} | |
] | |
}; | |
export const App = component$(() => { | |
const state = useStore({ | |
currentNode: fileSystemData, | |
path: [fileSystemData], | |
isCreating: null as 'file' | 'folder' | null, | |
newItemName: '', | |
newItemTags: '', | |
pendingOperations: [] as PendingOperation[] | |
}); | |
const autoSaveSignal = useSignal(0); | |
useTask$(({ track }) => { | |
track(() => autoSaveSignal.value); | |
if (state.pendingOperations.length > 0) { | |
savePendingChanges(); | |
} | |
}); | |
const navigateTo = $((node: Node) => { | |
if (node.type === 'directory') { | |
state.currentNode = node; | |
const nodeIndex = state.path.findIndex(n => n.id === node.id); | |
if (nodeIndex !== -1) { | |
state.path = state.path.slice(0, nodeIndex + 1); | |
} else { | |
state.path = [...state.path, node]; | |
} | |
} | |
}); | |
const navigateUp = $(() => { | |
if (state.path.length > 1) { | |
state.path = state.path.slice(0, -1); | |
state.currentNode = state.path[state.path.length - 1]; | |
} | |
}); | |
const createNewItem = $(() => { | |
if (!state.newItemName) return; | |
const newItem: Node = { | |
id: Date.now().toString(), | |
name: state.newItemName, | |
type: state.isCreating === 'file' ? 'file' : 'directory', | |
tags: state.newItemTags.split(',').map(tag => tag.trim()), | |
...(state.isCreating === 'file' ? { attributes: { size: 0 } } : { children: [] }), | |
pending: true | |
}; | |
const newOperation: PendingOperation = { | |
type: 'create', | |
node: newItem, | |
path: state.path.map(n => n.id) | |
}; | |
state.pendingOperations = [...state.pendingOperations, newOperation]; | |
state.currentNode.children = [...(state.currentNode.children || []), newItem]; | |
state.isCreating = null; | |
state.newItemName = ''; | |
state.newItemTags = ''; | |
}); | |
const savePendingChanges = $(async () => { | |
// Simulate an API call to save changes | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
// Process each pending operation | |
state.pendingOperations.forEach(op => { | |
if (op.type === 'create') { | |
// In a real app, you'd update the server and get a new ID | |
op.node.pending = false; | |
} | |
// Handle other operation types as needed | |
}); | |
// Clear pending operations | |
state.pendingOperations = []; | |
// Update the current node to reflect saved changes | |
if (state.currentNode.children) { | |
state.currentNode.children = state.currentNode.children.map(child => ({ | |
...child, | |
pending: false | |
})); | |
} | |
}); | |
// Set up auto-save every 5 seconds | |
setInterval(() => { | |
autoSaveSignal.value++; | |
}, 5000); | |
return ( | |
<div class="p-4"> | |
<h1 class="text-2xl font-bold mb-4">File System Navigation</h1> | |
<div class="mb-4 flex items-center"> | |
<button | |
onClick$={navigateUp} | |
class="bg-blue-500 text-white px-2 py-1 rounded mr-2 hover:bg-blue-600 transition-colors duration-200" | |
disabled={state.path.length === 1} | |
> | |
Up | |
</button> | |
{state.path.map((node, index) => ( | |
<span key={node.id}> | |
<span | |
class="text-blue-500 hover:underline cursor-pointer" | |
onClick$={() => navigateTo(node)} | |
> | |
{node.name} | |
</span> | |
{index < state.path.length - 1 && <span class="mx-2">/</span>} | |
</span> | |
))} | |
</div> | |
{state.pendingOperations.length > 0 && ( | |
<div class="mb-4 text-yellow-600"> | |
{state.pendingOperations.length} unsaved changes. | |
<button | |
onClick$={savePendingChanges} | |
class="ml-2 bg-yellow-500 text-white px-2 py-1 rounded hover:bg-yellow-600" | |
> | |
Save Now | |
</button> | |
</div> | |
)} | |
<div class="mb-4"> | |
<h2 class="text-xl font-semibold mb-2">Directories</h2> | |
<div class="grid grid-cols-4 gap-4"> | |
{state.currentNode.children?.filter(node => node.type === 'directory').map(node => ( | |
<div | |
key={node.id} | |
class={`border p-2 rounded cursor-pointer transition-colors duration-200 ${ | |
node.pending ? 'bg-yellow-100' : 'bg-blue-100' | |
} hover:bg-blue-200`} | |
onClick$={() => navigateTo(node)} | |
> | |
<div class="font-bold">{node.name}</div> | |
<div class="text-sm text-gray-500">{node.type}</div> | |
<div class="text-xs text-gray-400">{node.tags.join(', ')}</div> | |
{node.pending && <div class="text-xs text-yellow-600">Pending</div>} | |
</div> | |
))} | |
<div | |
class="border-2 border-dashed border-gray-300 p-2 rounded cursor-pointer transition-colors duration-200 hover:bg-gray-100 flex items-center justify-center" | |
onClick$={() => state.isCreating = 'folder'} | |
> | |
<span class="text-gray-500">+ New Folder</span> | |
</div> | |
</div> | |
</div> | |
<div> | |
<h2 class="text-xl font-semibold mb-2">Files</h2> | |
<div class="grid grid-cols-4 gap-4"> | |
{state.currentNode.children?.filter(node => node.type === 'file').map(node => ( | |
<div | |
key={node.id} | |
class={`border p-2 rounded cursor-pointer transition-colors duration-200 ${ | |
node.pending ? 'bg-yellow-100' : 'bg-white' | |
} hover:bg-gray-100`} | |
> | |
<div class="font-bold">{node.name}</div> | |
<div class="text-sm text-gray-500">{node.type}</div> | |
<div class="text-xs text-gray-400">{node.tags.join(', ')}</div> | |
{node.attributes?.size && ( | |
<div class="text-xs text-gray-400">Size: {node.attributes.size} bytes</div> | |
)} | |
{node.pending && <div class="text-xs text-yellow-600">Pending</div>} | |
</div> | |
))} | |
<div | |
class="border-2 border-dashed border-gray-300 p-2 rounded cursor-pointer transition-colors duration-200 hover:bg-gray-100 flex items-center justify-center" | |
onClick$={() => state.isCreating = 'file'} | |
> | |
<span class="text-gray-500">+ New File</span> | |
</div> | |
</div> | |
</div> | |
{state.isCreating && ( | |
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"> | |
<div class="bg-white p-4 rounded"> | |
<h3 class="font-bold mb-2">Create New {state.isCreating === 'file' ? 'File' : 'Folder'}</h3> | |
<input | |
type="text" | |
placeholder="Name" | |
value={state.newItemName} | |
onInput$={(e) => state.newItemName = (e.target as HTMLInputElement).value} | |
class="border p-1 mb-2 w-full" | |
/> | |
<input | |
type="text" | |
placeholder="Tags (comma-separated)" | |
value={state.newItemTags} | |
onInput$={(e) => state.newItemTags = (e.target as HTMLInputElement).value} | |
class="border p-1 mb-2 w-full" | |
/> | |
<div> | |
<button | |
onClick$={createNewItem} | |
class="bg-green-500 text-white px-2 py-1 rounded mr-2 hover:bg-green-600" | |
> | |
Create | |
</button> | |
<button | |
onClick$={() => state.isCreating = null} | |
class="bg-red-500 text-white px-2 py-1 rounded hover:bg-red-600" | |
> | |
Cancel | |
</button> | |
</div> | |
</div> | |
</div> | |
)} | |
</div> | |
); | |
}); | |
export default App; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useState, useEffect } from 'react'; | |
// Define the Node type | |
type Node = { | |
id: string; | |
name: string; | |
type: string; | |
tags: string[]; | |
children?: Node[]; | |
attributes?: Record<string, unknown>; | |
pending?: boolean; | |
}; | |
// Define the PendingOperation type | |
type PendingOperation = { | |
type: 'create' | 'update' | 'delete'; | |
node: Node; | |
path: string[]; | |
}; | |
// Sample data (unchanged) | |
const fileSystemData: Node = { | |
// ... (previous sample data) | |
}; | |
// Main App component | |
const App = () => { | |
const [currentNode, setCurrentNode] = useState<Node>(fileSystemData); | |
const [path, setPath] = useState<Node[]>([fileSystemData]); | |
const [isCreating, setIsCreating] = useState<'file' | 'folder' | null>(null); | |
const [newItemName, setNewItemName] = useState(''); | |
const [newItemTags, setNewItemTags] = useState(''); | |
const [pendingOperations, setPendingOperations] = useState<PendingOperation[]>([]); | |
const navigateTo = (node: Node) => { | |
if (node.type === 'directory') { | |
setCurrentNode(node); | |
const nodeIndex = path.findIndex(n => n.id === node.id); | |
if (nodeIndex !== -1) { | |
setPath(path.slice(0, nodeIndex + 1)); | |
} else { | |
setPath([...path, node]); | |
} | |
} | |
}; | |
const navigateUp = () => { | |
if (path.length > 1) { | |
const newPath = path.slice(0, -1); | |
setPath(newPath); | |
setCurrentNode(newPath[newPath.length - 1]); | |
} | |
}; | |
const createNewItem = () => { | |
if (!newItemName) return; | |
const newItem: Node = { | |
id: Date.now().toString(), | |
name: newItemName, | |
type: isCreating === 'file' ? 'file' : 'directory', | |
tags: newItemTags.split(',').map(tag => tag.trim()), | |
...(isCreating === 'file' ? { attributes: { size: 0 } } : { children: [] }), | |
pending: true | |
}; | |
const newOperation: PendingOperation = { | |
type: 'create', | |
node: newItem, | |
path: path.map(n => n.id) | |
}; | |
setPendingOperations([...pendingOperations, newOperation]); | |
setCurrentNode(prevNode => ({ | |
...prevNode, | |
children: [...(prevNode.children || []), newItem] | |
})); | |
setIsCreating(null); | |
setNewItemName(''); | |
setNewItemTags(''); | |
}; | |
const savePendingChanges = async () => { | |
// Simulate an API call to save changes | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
// Process each pending operation | |
pendingOperations.forEach(op => { | |
if (op.type === 'create') { | |
// In a real app, you'd update the server and get a new ID | |
op.node.pending = false; | |
} | |
// Handle other operation types as needed | |
}); | |
// Clear pending operations | |
setPendingOperations([]); | |
// Update the current node to reflect saved changes | |
setCurrentNode(prevNode => ({ | |
...prevNode, | |
children: prevNode.children?.map(child => ({ | |
...child, | |
pending: false | |
})) | |
})); | |
}; | |
useEffect(() => { | |
// Auto-save changes every 5 seconds if there are pending operations | |
const autoSaveInterval = setInterval(() => { | |
if (pendingOperations.length > 0) { | |
savePendingChanges(); | |
} | |
}, 5000); | |
return () => clearInterval(autoSaveInterval); | |
}, [pendingOperations]); | |
const directories = currentNode.children?.filter(node => node.type === 'directory') || []; | |
const files = currentNode.children?.filter(node => node.type === 'file') || []; | |
return ( | |
<div className="p-4"> | |
<h1 className="text-2xl font-bold mb-4">File System Navigation</h1> | |
<div className="mb-4 flex items-center"> | |
<button | |
onClick={navigateUp} | |
className="bg-blue-500 text-white px-2 py-1 rounded mr-2 hover:bg-blue-600 transition-colors duration-200" | |
disabled={path.length === 1} | |
> | |
Up | |
</button> | |
{path.map((node, index) => ( | |
<span key={node.id}> | |
<span | |
className="text-blue-500 hover:underline cursor-pointer" | |
onClick={() => navigateTo(node)} | |
> | |
{node.name} | |
</span> | |
{index < path.length - 1 && <span className="mx-2">/</span>} | |
</span> | |
))} | |
</div> | |
{pendingOperations.length > 0 && ( | |
<div className="mb-4 text-yellow-600"> | |
{pendingOperations.length} unsaved changes. | |
<button | |
onClick={savePendingChanges} | |
className="ml-2 bg-yellow-500 text-white px-2 py-1 rounded hover:bg-yellow-600" | |
> | |
Save Now | |
</button> | |
</div> | |
)} | |
<div className="mb-4"> | |
<h2 className="text-xl font-semibold mb-2">Directories</h2> | |
<div className="grid grid-cols-4 gap-4"> | |
{directories.map(node => ( | |
<div | |
key={node.id} | |
className={`border p-2 rounded cursor-pointer transition-colors duration-200 ${ | |
node.pending ? 'bg-yellow-100' : 'bg-blue-100' | |
} hover:bg-blue-200`} | |
onClick={() => navigateTo(node)} | |
> | |
<div className="font-bold">{node.name}</div> | |
<div className="text-sm text-gray-500">{node.type}</div> | |
<div className="text-xs text-gray-400">{node.tags.join(', ')}</div> | |
{node.pending && <div className="text-xs text-yellow-600">Pending</div>} | |
</div> | |
))} | |
<div | |
className="border-2 border-dashed border-gray-300 p-2 rounded cursor-pointer transition-colors duration-200 hover:bg-gray-100 flex items-center justify-center" | |
onClick={() => setIsCreating('folder')} | |
> | |
<span className="text-gray-500">+ New Folder</span> | |
</div> | |
</div> | |
</div> | |
<div> | |
<h2 className="text-xl font-semibold mb-2">Files</h2> | |
<div className="grid grid-cols-4 gap-4"> | |
{files.map(node => ( | |
<div | |
key={node.id} | |
className={`border p-2 rounded cursor-pointer transition-colors duration-200 ${ | |
node.pending ? 'bg-yellow-100' : 'bg-white' | |
} hover:bg-gray-100`} | |
> | |
<div className="font-bold">{node.name}</div> | |
<div className="text-sm text-gray-500">{node.type}</div> | |
<div className="text-xs text-gray-400">{node.tags.join(', ')}</div> | |
{node.attributes?.size && ( | |
<div className="text-xs text-gray-400">Size: {node.attributes.size} bytes</div> | |
)} | |
{node.pending && <div className="text-xs text-yellow-600">Pending</div>} | |
</div> | |
))} | |
<div | |
className="border-2 border-dashed border-gray-300 p-2 rounded cursor-pointer transition-colors duration-200 hover:bg-gray-100 flex items-center justify-center" | |
onClick={() => setIsCreating('file')} | |
> | |
<span className="text-gray-500">+ New File</span> | |
</div> | |
</div> | |
</div> | |
{isCreating && ( | |
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"> | |
<div className="bg-white p-4 rounded"> | |
<h3 className="font-bold mb-2">Create New {isCreating === 'file' ? 'File' : 'Folder'}</h3> | |
<input | |
type="text" | |
placeholder="Name" | |
value={newItemName} | |
onChange={(e) => setNewItemName(e.target.value)} | |
className="border p-1 mb-2 w-full" | |
/> | |
<input | |
type="text" | |
placeholder="Tags (comma-separated)" | |
value={newItemTags} | |
onChange={(e) => setNewItemTags(e.target.value)} | |
className="border p-1 mb-2 w-full" | |
/> | |
<div> | |
<button | |
onClick={createNewItem} | |
className="bg-green-500 text-white px-2 py-1 rounded mr-2 hover:bg-green-600" | |
> | |
Create | |
</button> | |
<button | |
onClick={() => setIsCreating(null)} | |
className="bg-red-500 text-white px-2 py-1 rounded hover:bg-red-600" | |
> | |
Cancel | |
</button> | |
</div> | |
</div> | |
</div> | |
)} | |
</div> | |
); | |
}; | |
export default App; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment