import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { ArrowLeft, Box, Check, ChevronLeft, ChevronRight, Cog, Download, ExternalLink, FolderOpen, Loader2, Search, Settings as SettingsIcon, } from 'lucide-react'; import { api, ApiError, type LibraryFolderTree, type ManyfoldFile, type ManyfoldModel, } from '../api/client'; import { Button } from './Button'; import { Card, CardContent, CardHeader } from './Card'; import { ConfirmModal } from './ConfirmModal'; import { SliceModal, type SliceSource } from './SliceModal'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; import { isSliceableLibraryFile } from '../utils/libraryFiles'; import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer'; import { useUiSettings } from '../hooks/useUiSettings'; const MANYFOLD_ERROR_CODES = new Set([ 'manyfold_not_configured', 'manyfold_credentials', 'manyfold_scope', 'manyfold_rate_limited', 'manyfold_unreachable', 'manyfold_forbidden', 'manyfold_not_found', 'manyfold_too_large', 'manyfold_not_importable', 'manyfold_bad_url', 'manyfold_secret_required', 'manyfold_failed', ]); /** The backend names each Manyfold failure with a code; show our own text for it. */ function errorText(err: unknown, t: TFunction): string { if (err instanceof ApiError && err.code && MANYFOLD_ERROR_CODES.has(err.code)) { return t(`manyfold.errors.${err.code}`); } return err instanceof Error && err.message ? err.message : t('manyfold.errors.manyfold_failed'); } function fileTypeLabel(mime: string): string { const subtype = mime.split('/')[1] ?? ''; return (subtype.replace(/^x-/, '').split(/[+.;]/)[0] || '?').toUpperCase(); } type FlatFolder = { folder: LibraryFolderTree; depth: number }; function flattenFolderTree(tree: LibraryFolderTree, depth = 0, out: FlatFolder[] = []): FlatFolder[] { out.push({ folder: tree, depth }); for (const child of tree.children ?? []) flattenFolderTree(child, depth + 1, out); return out; } /** A model's preview from Manyfold, or a placeholder when it has none. */ function ManyfoldPreview({ modelId, className }: { modelId: string; className?: string }) { const { data: blob, isLoading } = useQuery({ queryKey: ['manyfold-preview', modelId], queryFn: () => api.getManyfoldPreview(modelId), staleTime: Infinity, retry: false, }); const [src, setSrc] = useState(null); useEffect(() => { if (!blob) { setSrc(null); return; } const url = URL.createObjectURL(blob); setSrc(url); return () => URL.revokeObjectURL(url); }, [blob]); return (
{src ? ( ) : isLoading ? ( ) : ( )}
); } /** URL, client ID and secret of the Manyfold OAuth application. */ function ManyfoldConnectionCard({ onDone }: { onDone?: () => void }) { const { t } = useTranslation(); const { showToast } = useToast(); const queryClient = useQueryClient(); const configQuery = useQuery({ queryKey: ['manyfold-config'], queryFn: () => api.getManyfoldConfig() }); const [url, setUrl] = useState(''); const [clientId, setClientId] = useState(''); const [secret, setSecret] = useState(''); const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null); const [confirmDisconnect, setConfirmDisconnect] = useState(false); useEffect(() => { if (configQuery.data) { setUrl(configQuery.data.url); setClientId(configQuery.data.client_id); } }, [configQuery.data]); const hasStoredSecret = configQuery.data?.has_client_secret ?? false; const input = { url: url.trim(), client_id: clientId.trim(), client_secret: secret.trim() || undefined }; const complete = !!input.url && !!input.client_id && (!!input.client_secret || hasStoredSecret); const refresh = () => { queryClient.invalidateQueries({ queryKey: ['manyfold-config'] }); queryClient.invalidateQueries({ queryKey: ['manyfold-status'] }); queryClient.invalidateQueries({ queryKey: ['manyfold-models'] }); queryClient.invalidateQueries({ queryKey: ['manyfold-model'] }); queryClient.invalidateQueries({ queryKey: ['manyfold-preview'] }); }; const testMutation = useMutation({ mutationFn: () => api.testManyfoldConfig(input), onSuccess: (data) => setTestResult({ ok: true, text: t('manyfold.testOk', { count: data.model_count }) }), onError: (err) => setTestResult({ ok: false, text: errorText(err, t) }), }); const saveMutation = useMutation({ mutationFn: () => api.updateManyfoldConfig(input), onSuccess: () => { setSecret(''); refresh(); showToast(t('settings.toast.settingsSaved'), 'success'); onDone?.(); }, onError: (err) => showToast(errorText(err, t), 'error'), }); const disconnectMutation = useMutation({ mutationFn: () => api.deleteManyfoldConfig(), onSuccess: () => { setConfirmDisconnect(false); setUrl(''); setClientId(''); setSecret(''); setTestResult(null); refresh(); showToast(t('manyfold.disconnected'), 'success'); }, onError: (err) => { setConfirmDisconnect(false); showToast(errorText(err, t), 'error'); }, }); const fieldClass = 'w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none'; return (

{t('manyfold.connectionTitle')}

{t('manyfold.connectionHelp')}

{ e.preventDefault(); if (complete) saveMutation.mutate(); }} >
{ setUrl(e.target.value); setTestResult(null); }} placeholder={t('manyfold.urlPlaceholder')} className={fieldClass} autoComplete="off" />
{ setClientId(e.target.value); setTestResult(null); }} className={fieldClass} autoComplete="off" />
{ setSecret(e.target.value); setTestResult(null); }} placeholder={hasStoredSecret ? t('manyfold.clientSecretStored') : ''} className={fieldClass} autoComplete="new-password" />
{testResult && (

{testResult.text}

)}
{configQuery.data?.configured && ( )} {onDone && configQuery.data?.configured && ( )}
{confirmDisconnect && ( setConfirmDisconnect(false)} onConfirm={() => disconnectMutation.mutate()} /> )}
); } /** The library folder imports land in; empty means the "Manyfold" folder. */ function ImportFolderSelect({ value, onChange, disabled, }: { value: number | null; onChange: (folderId: number | null) => void; disabled?: boolean; }) { const { t } = useTranslation(); const foldersQuery = useQuery({ queryKey: ['library-folders'], queryFn: () => api.getLibraryFolders() }); const options = (foldersQuery.data ?? []) .filter((f) => !(f.is_external && f.external_readonly)) .flatMap((f) => flattenFolderTree(f)); return ( ); } type BulkItem = { modelId: string; fileId: string; name: string }; /** The first few names, then how many more. */ function listNames(names: string[]): string { return names.length > 3 ? `${names.slice(0, 3).join(', ')} +${names.length - 3}` : names.join(', '); } /** * Imports several files one after another, not in parallel, to go easy on * Manyfold and the disk. A file that fails doesn't stop the rest; one summary * at the end says what happened. */ function useManyfoldBulkImport() { const { t } = useTranslation(); const { showToast } = useToast(); const queryClient = useQueryClient(); const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); const run = async (items: BulkItem[], folderId: number | null) => { if (items.length === 0) { showToast(t('manyfold.bulkNothing'), 'info'); return; } let imported = 0; let existing = 0; const failed: string[] = []; let firstError: unknown = null; try { for (let i = 0; i < items.length; i += 1) { setProgress({ current: i + 1, total: items.length }); try { const result = await api.importManyfoldFile(items[i].modelId, items[i].fileId, folderId); if (result.was_existing) existing += 1; else imported += 1; } catch (err) { failed.push(items[i].name); firstError ??= err; } } } finally { setProgress(null); queryClient.invalidateQueries({ queryKey: ['manyfold-model'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); } showToast( t('manyfold.bulkDone', { imported, existing, failed: failed.length }), failed.length ? 'warning' : 'success', ); if (failed.length) { showToast(t('manyfold.bulkFailed', { names: listNames(failed), reason: errorText(firstError, t) }), 'error'); } }; return { run, progress, busy: progress !== null }; } /** One model: its details and files, each importable into the library. */ function ManyfoldModelView({ modelId, onBack, onSlice, }: { modelId: string; onBack: () => void; onSlice: (libraryFileId: number, filename: string) => void; }) { const { t } = useTranslation(); const { hasPermission } = useAuth(); const { showToast } = useToast(); const queryClient = useQueryClient(); const navigate = useNavigate(); const canImport = hasPermission('manyfold:import'); const [folderId, setFolderId] = useState(null); const [importingFileId, setImportingFileId] = useState(null); const [checked, setChecked] = useState>(new Set()); const bulk = useManyfoldBulkImport(); const modelQuery = useQuery({ queryKey: ['manyfold-model', modelId], queryFn: () => api.getManyfoldModel(modelId), }); const settingsQuery = useUiSettings(); const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false; const desktopSlicer: SlicerType = resolveDesktopSlicer( settingsQuery.data?.open_in_slicer, settingsQuery.data?.preferred_slicer, ); const importMutation = useMutation({ mutationFn: (file: ManyfoldFile) => api.importManyfoldFile(modelId, file.id, folderId), onMutate: (file) => setImportingFileId(file.id), onSettled: () => setImportingFileId(null), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['manyfold-model', modelId] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); showToast( data.was_existing ? t('manyfold.alreadyInLibrary') : t('manyfold.imported', { filename: data.filename }), 'success', ); }, onError: (err) => showToast(errorText(err, t), 'error'), }); if (modelQuery.isLoading) { return (
); } if (modelQuery.isError || !modelQuery.data) { return (

{errorText(modelQuery.error, t)}

); } const model: ManyfoldModel = modelQuery.data; // Files that can still be imported: printable and not in the library yet. const candidates = model.files.filter((f) => f.importable && !f.library_file); const selected = candidates.filter((f) => checked.has(f.id)); const allSelected = candidates.length > 0 && selected.length === candidates.length; const busy = bulk.busy || importMutation.isPending; const importFiles = async (files: ManyfoldFile[]) => { await bulk.run( files.map((f) => ({ modelId: model.id, fileId: f.id, name: f.name })), folderId, ); setChecked(new Set()); }; return (

{model.name}

{model.caption &&

{model.caption}

}
{model.description && (

{model.description}

)} {model.tags.length > 0 && (
{model.tags.map((tag) => ( {tag} ))}
)}
{model.license && ( {t('manyfold.license')}: {model.license} )} {t('manyfold.openInManyfold')}

{t('manyfold.files')}

{canImport && }
{canImport && candidates.length > 0 && (
{bulk.progress ? ( {t('manyfold.importProgress', bulk.progress)} ) : ( <> )}
)}
{model.files.length === 0 ? (

{t('manyfold.noFiles')}

) : (
    {model.files.map((file) => { const imported = file.library_file; const sliceable = imported !== null && isSliceableLibraryFile({ filename: imported.filename }, useSlicerApi, desktopSlicer); return (
  • {canImport && candidates.length > 0 && ( {file.importable && !imported && ( setChecked((prev) => { const next = new Set(prev); if (next.has(file.id)) next.delete(file.id); else next.add(file.id); return next; }) } className="accent-bambu-green" /> )} )} {fileTypeLabel(file.mime)} {file.name}
    {!file.importable ? ( {t('manyfold.notImportable')} ) : imported ? ( <> {t('manyfold.inLibrary')} {sliceable && ( )} ) : canImport ? ( ) : null}
  • ); })}
)}
); } /** The Manyfold tab of the Model Sources page (#1471). */ export function ManyfoldTab() { const { t } = useTranslation(); const { hasPermission } = useAuth(); const canConfigure = hasPermission('settings:update'); const [editingConnection, setEditingConnection] = useState(false); const [queryInput, setQueryInput] = useState(''); const [query, setQuery] = useState(''); const [page, setPage] = useState(1); const [selectedModelId, setSelectedModelId] = useState(null); const [sliceSource, setSliceSource] = useState(null); // Models ticked in the grid, kept across pages and searches (id -> name). const [picked, setPicked] = useState>(new Map()); const [gridFolderId, setGridFolderId] = useState(null); const [collecting, setCollecting] = useState<{ current: number; total: number } | null>(null); const canImport = hasPermission('manyfold:import'); const queryClient = useQueryClient(); const { showToast } = useToast(); const gridBulk = useManyfoldBulkImport(); const gridBusy = collecting !== null || gridBulk.busy; const gridProgress = collecting ? t('manyfold.collectProgress', collecting) : gridBulk.progress ? t('manyfold.importProgress', gridBulk.progress) : null; const togglePicked = (id: string, name: string) => setPicked((prev) => { const next = new Map(prev); if (next.has(id)) next.delete(id); else next.set(id, name); return next; }); // Every printable file of the ticked models that isn't in the library yet. // The models are read one after another first, then their files imported. const importPicked = async () => { const models = [...picked.keys()]; const items: BulkItem[] = []; const unreadable: string[] = []; try { for (let i = 0; i < models.length; i += 1) { setCollecting({ current: i + 1, total: models.length }); try { const model = await queryClient.fetchQuery({ queryKey: ['manyfold-model', models[i]], queryFn: () => api.getManyfoldModel(models[i]), }); for (const f of model.files) { if (f.importable && !f.library_file) items.push({ modelId: model.id, fileId: f.id, name: `${model.name}: ${f.name}` }); } } catch { unreadable.push(picked.get(models[i]) ?? models[i]); } } } finally { setCollecting(null); } if (unreadable.length) showToast(t('manyfold.modelsUnreadable', { names: listNames(unreadable) }), 'error'); await gridBulk.run(items, gridFolderId); setPicked(new Map()); }; const settingsQuery = useUiSettings(); const statusQuery = useQuery({ queryKey: ['manyfold-status'], queryFn: () => api.getManyfoldStatus() }); const configured = statusQuery.data?.configured ?? false; const modelsQuery = useQuery({ queryKey: ['manyfold-models', query, page], queryFn: () => api.listManyfoldModels(query, page), enabled: configured, placeholderData: (previous) => previous, }); // Slicing follows the same preference the MakerWorld tab and the File // Manager use: the in-app slicer when Use Slicer API is on, otherwise a // handoff to the desktop slicer. const handleSlice = async (libraryFileId: number, filename: string) => { if (settingsQuery.data?.use_slicer_api) { setSliceSource({ kind: 'libraryFile', id: libraryFileId, filename }); return; } const slicer = resolveDesktopSlicer(settingsQuery.data?.open_in_slicer, settingsQuery.data?.preferred_slicer); try { const { token } = await api.createLibrarySlicerToken(libraryFileId); openInSlicer(`${window.location.origin}${api.getLibrarySlicerDownloadUrl(libraryFileId, token, filename)}`, slicer); } catch { // Auth disabled: the plain download URL is public then. openInSlicer(`${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId)}`, slicer); } }; if (statusQuery.isLoading) { return (
); } if (!configured || editingConnection) { return (

{t('manyfold.description')}

{canConfigure ? ( setEditingConnection(false) : undefined} /> ) : (

{t('manyfold.notConnectedTitle')}

{t('manyfold.notConnectedBody')}

)}
); } const listing = modelsQuery.data; return (
{selectedModelId ? ( setSelectedModelId(null)} onSlice={handleSlice} /> ) : ( <>

{t('manyfold.description')}

{canConfigure && ( )}
{ e.preventDefault(); setQuery(queryInput.trim()); setPage(1); }} >
setQueryInput(e.target.value)} placeholder={t('manyfold.searchPlaceholder')} aria-label={t('manyfold.searchPlaceholder')} className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" />
{modelsQuery.isError ? (

{errorText(modelsQuery.error, t)}

) : !listing ? (
) : listing.models.length === 0 ? (

{t('manyfold.noModels')}

) : ( <>

{t('manyfold.modelCount', { count: listing.total })}

{canImport && ( )}
{canImport && picked.size > 0 && (
{t('manyfold.modelsSelected', { count: picked.size })} {gridProgress ? ( {gridProgress} ) : ( <> )}
)}
{listing.models.map((model) => { const isPicked = picked.has(model.id); return (
{/* Locked while the ticked models import, so a file can't be imported twice at once from the model view. */} {canImport && ( )}
); })}
{(listing.has_previous || listing.has_next) && (
{t('manyfold.page', { page: listing.page })}
)} )} )} {sliceSource && setSliceSource(null)} />}
); }