Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 3 additions & 19 deletions examples/file-browser/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useCloudCannonAPI } from './hooks/useCloudCannonAPI';
function App() {
const [selectedFile, setSelectedFile] = useState<CloudCannonJavaScriptV1APIFile | null>(null);

const { api, isLoading, error, files, refreshFiles } = useCloudCannonAPI();
const { api, isLoading, error, files, refreshFiles, collections } = useCloudCannonAPI();

const handleFileSelect = (file: CloudCannonJavaScriptV1APIFile) => {
setSelectedFile(file);
Expand Down Expand Up @@ -69,27 +69,12 @@ function App() {

return (
<div className="h-screen flex flex-col bg-gray-100">
{/* Header */}
<header className="bg-white border-b border-gray-200 px-4 py-3">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-800">CloudCannon File Browser</h1>
<div className="flex items-center space-x-2 text-sm text-gray-500">
<span>{files.length} files</span>
{selectedFile && (
<>
<span>•</span>
<span>Editing: {selectedFile.path}</span>
</>
)}
</div>
</div>
</header>

{/* Main content */}
<div className="flex-1 flex overflow-hidden">
{/* File browser sidebar */}
<div className="w-80 flex-shrink-0">
<FileBrowser
collections={collections}
files={files}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
Expand All @@ -108,6 +93,7 @@ function App() {
{/* Status bar */}
<footer className="bg-blue-600 text-white px-4 py-2 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4" />
<div className="flex items-center space-x-4">
<span>CloudCannon API v1</span>
{api && (
Expand All @@ -116,8 +102,6 @@ function App() {
Connected
</span>
)}
</div>
<div className="flex items-center space-x-4">
{selectedFile && (
<span>Language: {selectedFile.path.split('.').pop()?.toUpperCase() || 'PLAIN'}</span>
)}
Expand Down
5 changes: 5 additions & 0 deletions examples/file-browser/src/components/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export function CodeEditor({ file, onSave }: CodeEditorProps) {
};

loadFileContent();

file.addEventListener('change', async () => {
const fileContent = await file.get();
setOriginalContent(fileContent);
});
}, [file]);

useEffect(() => {
Expand Down
68 changes: 68 additions & 0 deletions examples/file-browser/src/components/CollectionBrowser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type {
CloudCannonJavaScriptV1APICollection,
CloudCannonJavaScriptV1APIFile,
} from '@cloudcannon/javascript-api';
import { RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { FileTree } from './FileTree';

interface CollectionBrowserProps {
collection: CloudCannonJavaScriptV1APICollection;
selectedFile: CloudCannonJavaScriptV1APIFile | null;
onFileSelect: (file: CloudCannonJavaScriptV1APIFile) => void;
isLoading: boolean;
onRefresh: () => void;
}

export function CollectionBrowser({
collection,
selectedFile,
onFileSelect,
isLoading,
onRefresh,
}: CollectionBrowserProps) {
const [items, setItems] = useState<CloudCannonJavaScriptV1APIFile[] | undefined>(undefined);

useEffect(() => {
const handleFileChange = async () => {
const items = await collection.items();
setItems(items);
};

collection.addEventListener('change', handleFileChange);
collection.addEventListener('create', handleFileChange);
collection.addEventListener('delete', handleFileChange);
handleFileChange();

return () => {
collection.removeEventListener('change', handleFileChange);
collection.removeEventListener('create', handleFileChange);
collection.removeEventListener('delete', handleFileChange);
};
}, [collection]);

return (
<div className="flex flex-col bg-white border-r border-gray-200">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-t border-gray-200 bg-gray-50">
<h2 className="font-medium text-gray-700">{collection.collectionKey}</h2>
<button
type="button"
onClick={onRefresh}
disabled={isLoading}
className="p-1 text-gray-500 hover:text-gray-700 disabled:opacity-50"
title="Refresh collection"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
</button>
</div>

<FileTree
files={items || []}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
isLoading={isLoading || !items}
/>
</div>
);
}
183 changes: 27 additions & 156 deletions examples/file-browser/src/components/FileBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,152 +1,30 @@
import type { CloudCannonJavaScriptV1APIFile } from '@cloudcannon/javascript-api';
import { AlertCircle, ChevronDown, ChevronRight, File, Folder, RefreshCw } from 'lucide-react';
import { useMemo, useState } from 'react';
import type {
CloudCannonJavaScriptV1APICollection,
CloudCannonJavaScriptV1APIFile,
} from '@cloudcannon/javascript-api';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { CollectionBrowser } from './CollectionBrowser';
import { FileTree } from './FileTree';

interface FileBrowserProps {
files: CloudCannonJavaScriptV1APIFile[];
collections: CloudCannonJavaScriptV1APICollection[];
selectedFile: CloudCannonJavaScriptV1APIFile | null;
onFileSelect: (file: CloudCannonJavaScriptV1APIFile) => void;
isLoading: boolean;
error: string | null;
onRefresh: () => void;
}

interface FileTreeNode {
name: string;
path: string;
type: 'file' | 'directory';
children?: Record<string, FileTreeNode>;
file?: CloudCannonJavaScriptV1APIFile;
}

function buildFileTree(files: CloudCannonJavaScriptV1APIFile[]): FileTreeNode[] {
const root: Record<string, FileTreeNode> = {};

files.forEach((file) => {
const parts = file.path.split('/').filter(Boolean);
let current = root;

parts.forEach((part, index) => {
const isFile = index === parts.length - 1;
const currentPath = parts.slice(0, index + 1).join('/');

if (!current[part]) {
current[part] = {
name: part,
path: currentPath,
type: isFile ? 'file' : 'directory',
children: isFile ? undefined : {},
file: isFile ? file : undefined,
};
}

if (!isFile) {
current = current[part].children as Record<string, FileTreeNode>;
}
});
});

// Convert to array and sort
const sortNodes = (nodes: Record<string, FileTreeNode>): FileTreeNode[] => {
return Object.values(nodes)
.map((node) => ({
...node,
children: node.children ? sortNodes(node.children) : {},
}))
.sort((a, b) => {
// Directories first, then files
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
};

return sortNodes(root);
}

interface FileTreeItemProps {
node: FileTreeNode;
selectedFile: CloudCannonJavaScriptV1APIFile | null;
onFileSelect: (file: CloudCannonJavaScriptV1APIFile) => void;
level: number;
}

function FileTreeItem({ node, selectedFile, onFileSelect, level }: FileTreeItemProps) {
const [isExpanded, setIsExpanded] = useState(level < 2); // Auto-expand first two levels
const isSelected = selectedFile?.path === node.path;

const handleClick = () => {
if (node.type === 'directory') {
setIsExpanded(!isExpanded);
} else if (node.file) {
onFileSelect(node.file);
}
};

const getFileIcon = (_filename: string) => {
// const ext = filename.split('.').pop()?.toLowerCase();
// You could expand this with more specific file type icons
return <File size={16} />;
};

return (
<div>
<div
className={`
flex items-center px-2 py-1 cursor-pointer hover:bg-gray-100
${isSelected ? 'bg-blue-100 border-r-2 border-blue-500' : ''}
`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
>
{node.type === 'directory' && (
<span className="mr-1 text-gray-400">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
)}

<span className="mr-2 text-gray-600">
{node.type === 'directory' ? <Folder size={16} /> : getFileIcon(node.name)}
</span>

<span className={`text-sm ${isSelected ? 'font-medium text-blue-700' : 'text-gray-700'}`}>
{node.name}
</span>
</div>

{node.type === 'directory' && isExpanded && node.children && (
<div>
{Object.values(node.children).map((child) => (
<FileTreeItem
key={child.path}
node={child}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}

export function FileBrowser({
files,
collections,
selectedFile,
onFileSelect,
isLoading,
error,
onRefresh,
}: FileBrowserProps) {
const fileTree = useMemo(() => buildFileTree(files), [files]);

if (error) {
return (
<div className="p-4">
Expand All @@ -168,8 +46,19 @@ export function FileBrowser({

return (
<div className="h-full flex flex-col bg-white border-r border-gray-200">
{collections.map((collection) => (
<CollectionBrowser
key={collection.collectionKey}
collection={collection}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
isLoading={isLoading}
onRefresh={onRefresh}
/>
))}

{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center justify-between p-3 border-b border-t border-gray-200 bg-gray-50">
<h2 className="font-medium text-gray-700">Files</h2>
<button
type="button"
Expand All @@ -183,30 +72,12 @@ export function FileBrowser({
</div>

{/* File tree */}
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="p-4 text-center text-gray-500">
<RefreshCw size={20} className="animate-spin mx-auto mb-2" />
<p className="text-sm">Loading files...</p>
</div>
) : fileTree.length === 0 ? (
<div className="p-4 text-center text-gray-500">
<p className="text-sm">No files found</p>
</div>
) : (
<div className="py-2">
{fileTree.map((node) => (
<FileTreeItem
key={node.path}
node={node}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
level={0}
/>
))}
</div>
)}
</div>
<FileTree
files={files}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
isLoading={isLoading}
/>
</div>
);
}
Loading