306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/**
|
||
* SharepointBrowseTree – Lazy-loading tree for SharePoint browse.
|
||
* Same look & feel as FolderTree (chevron, FaFolder/FaFolderOpen, styling).
|
||
* Loads children on expand via onLoadChildren(path).
|
||
*/
|
||
|
||
import React, { useState, useCallback, useEffect } from 'react';
|
||
import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa';
|
||
import styles from './FolderTree.module.css';
|
||
|
||
export interface BrowseEntry {
|
||
name: string;
|
||
path: string;
|
||
isFolder: boolean;
|
||
size?: number;
|
||
mimeType?: string;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface SharepointBrowseTreeProps {
|
||
/** Root path (usually "/") - children loaded via onLoadChildren */
|
||
rootPath?: string;
|
||
/** Load children for a given path. Returns folders and files. */
|
||
onLoadChildren: (path: string) => Promise<BrowseEntry[]>;
|
||
/** Called when user selects a file path */
|
||
onSelectFile: (path: string) => void;
|
||
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
|
||
onSelectFolder?: (path: string) => void;
|
||
/** Currently selected path (for highlight) */
|
||
selectedPath?: string | null;
|
||
/** Optional: pre-seed root children (e.g. from initial load) */
|
||
initialChildren?: BrowseEntry[];
|
||
}
|
||
|
||
function _fileIcon(mime?: string): string {
|
||
if (!mime) return '\uD83D\uDCC4';
|
||
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||
if (mime.includes('pdf')) return '\uD83D\uDCD5';
|
||
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
|
||
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
|
||
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
|
||
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
|
||
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
|
||
return '\uD83D\uDCC4';
|
||
}
|
||
|
||
/* ── File row ──────────────────────────────────────────────────────────── */
|
||
|
||
function _FileRow({
|
||
entry,
|
||
selectedPath,
|
||
onSelect,
|
||
}: {
|
||
entry: BrowseEntry;
|
||
selectedPath: string | null | undefined;
|
||
onSelect: (path: string) => void;
|
||
}) {
|
||
const isSelected = selectedPath === entry.path;
|
||
|
||
return (
|
||
<div
|
||
className={`${styles.treeNode} ${styles.fileNode} ${isSelected ? styles.selected : ''}`}
|
||
onClick={() => onSelect(entry.path)}
|
||
title={entry.path}
|
||
>
|
||
<span className={styles.chevron + ' ' + styles.empty} />
|
||
<span className={styles.fileIcon}>{_fileIcon(entry.mimeType)}</span>
|
||
<span className={styles.folderName}>{entry.name}</span>
|
||
{entry.size != null && (
|
||
<span className={styles.fileSize}>
|
||
{(entry.size / 1024).toFixed(0)}K
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── Folder row (expandable, lazy-loads children) ───────────────────────── */
|
||
|
||
function _FolderRow({
|
||
entry,
|
||
selectedPath,
|
||
expandedPaths,
|
||
loadedChildren,
|
||
loadingPaths,
|
||
onToggle,
|
||
onSelectFile,
|
||
onSelectFolder,
|
||
}: {
|
||
entry: BrowseEntry;
|
||
selectedPath: string | null | undefined;
|
||
expandedPaths: Set<string>;
|
||
loadedChildren: Record<string, BrowseEntry[]>;
|
||
loadingPaths: Set<string>;
|
||
onToggle: (path: string) => void;
|
||
onSelectFile: (path: string) => void;
|
||
onSelectFolder?: (path: string) => void;
|
||
}) {
|
||
const isExpanded = expandedPaths.has(entry.path);
|
||
const isSelected = selectedPath === entry.path;
|
||
const children = loadedChildren[entry.path] ?? [];
|
||
const folders = children.filter((c) => c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||
const files = children.filter((c) => !c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||
const isLoading = isExpanded && loadingPaths.has(entry.path);
|
||
|
||
const handleRowClick = (e: React.MouseEvent) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest(`.${styles.chevron}`)) return;
|
||
if (onSelectFolder) {
|
||
onSelectFolder(entry.path);
|
||
return;
|
||
}
|
||
onToggle(entry.path);
|
||
};
|
||
|
||
const handleChevronClick = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
onToggle(entry.path);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
className={`${styles.treeNode} ${onSelectFolder && isSelected ? styles.selected : ''}`}
|
||
onClick={handleRowClick}
|
||
title={entry.path}
|
||
>
|
||
<span
|
||
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}
|
||
onClick={handleChevronClick}
|
||
title={isExpanded ? 'Einklappen' : 'Erweitern'}
|
||
>
|
||
<FaChevronRight />
|
||
</span>
|
||
<span className={styles.folderIcon}>
|
||
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
|
||
</span>
|
||
<span className={styles.folderName}>{entry.name}</span>
|
||
{isLoading && (
|
||
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}>…</span>
|
||
)}
|
||
</div>
|
||
{isExpanded && (
|
||
<div className={styles.children}>
|
||
{isLoading ? (
|
||
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
|
||
Wird geladen…
|
||
</div>
|
||
) : (
|
||
<>
|
||
{folders.map((child) => (
|
||
<_FolderRow
|
||
key={child.path}
|
||
entry={child}
|
||
selectedPath={selectedPath}
|
||
expandedPaths={expandedPaths}
|
||
loadedChildren={loadedChildren}
|
||
loadingPaths={loadingPaths}
|
||
onToggle={onToggle}
|
||
onSelectFile={onSelectFile}
|
||
onSelectFolder={onSelectFolder}
|
||
/>
|
||
))}
|
||
{files.map((child) => (
|
||
<_FileRow
|
||
key={child.path}
|
||
entry={child}
|
||
selectedPath={selectedPath}
|
||
onSelect={onSelectFile}
|
||
/>
|
||
))}
|
||
{children.length === 0 && (
|
||
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
||
Leer
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── Root component ─────────────────────────────────────────────────────── */
|
||
|
||
export function SharepointBrowseTree({
|
||
rootPath = '/',
|
||
onLoadChildren,
|
||
onSelectFile,
|
||
onSelectFolder,
|
||
selectedPath,
|
||
initialChildren = [],
|
||
}: SharepointBrowseTreeProps) {
|
||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
|
||
const [loadedChildren, setLoadedChildren] = useState<Record<string, BrowseEntry[]>>(() =>
|
||
initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
|
||
);
|
||
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
|
||
|
||
const loadPath = useCallback(
|
||
async (path: string) => {
|
||
setLoadingPaths((p) => new Set(p).add(path));
|
||
try {
|
||
const items = await onLoadChildren(path);
|
||
setLoadedChildren((prev) => ({ ...prev, [path]: items }));
|
||
} catch {
|
||
setLoadedChildren((prev) => ({ ...prev, [path]: [] }));
|
||
} finally {
|
||
setLoadingPaths((p) => {
|
||
const next = new Set(p);
|
||
next.delete(path);
|
||
return next;
|
||
});
|
||
}
|
||
},
|
||
[onLoadChildren]
|
||
);
|
||
|
||
const handleToggle = useCallback(
|
||
(path: string) => {
|
||
setExpandedPaths((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(path)) {
|
||
next.delete(path);
|
||
} else {
|
||
next.add(path);
|
||
loadPath(path);
|
||
}
|
||
return next;
|
||
});
|
||
},
|
||
[loadPath]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (rootPath in loadedChildren) return;
|
||
if (initialChildren.length > 0) return;
|
||
loadPath(rootPath);
|
||
}, [rootPath, initialChildren.length, loadPath]);
|
||
|
||
const rootItems = loadedChildren[rootPath] ?? [];
|
||
const rootLoading = loadingPaths.has(rootPath);
|
||
const rootFolders = rootItems.filter((e) => e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||
const rootFiles = rootItems.filter((e) => !e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||
const isRootExpanded = expandedPaths.has(rootPath);
|
||
|
||
return (
|
||
<div className={styles.folderTree}>
|
||
<div
|
||
className={`${styles.treeNode} ${selectedPath === null || selectedPath === undefined ? styles.selected : ''}`}
|
||
style={{ fontWeight: 600 }}
|
||
>
|
||
<span
|
||
className={`${styles.chevron} ${isRootExpanded ? styles.expanded : ''}`}
|
||
onClick={() => handleToggle(rootPath)}
|
||
>
|
||
<FaChevronRight />
|
||
</span>
|
||
<span className={styles.folderIcon}><FaGlobe /></span>
|
||
<span className={`${styles.folderName} ${styles.rootLabel}`}>SharePoint</span>
|
||
{rootLoading && (
|
||
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}>…</span>
|
||
)}
|
||
</div>
|
||
{isRootExpanded && (
|
||
<div className={styles.children}>
|
||
{rootLoading ? (
|
||
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
|
||
Sites werden geladen…
|
||
</div>
|
||
) : (
|
||
<>
|
||
{rootFolders.map((entry) => (
|
||
<_FolderRow
|
||
key={entry.path}
|
||
entry={entry}
|
||
selectedPath={selectedPath}
|
||
expandedPaths={expandedPaths}
|
||
loadedChildren={loadedChildren}
|
||
loadingPaths={loadingPaths}
|
||
onToggle={handleToggle}
|
||
onSelectFile={onSelectFile}
|
||
onSelectFolder={onSelectFolder}
|
||
/>
|
||
))}
|
||
{rootFiles.map((entry) => (
|
||
<_FileRow
|
||
key={entry.path}
|
||
entry={entry}
|
||
selectedPath={selectedPath}
|
||
onSelect={onSelectFile}
|
||
/>
|
||
))}
|
||
{rootItems.length === 0 && !rootLoading && (
|
||
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
||
Keine Einträge
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|