mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
The app was built dark-first, so hundreds of hardcoded Tailwind semantic text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had no dark: variant. With darkMode:'class' they applied in light theme too, producing washed-out text on pale tints and white cards — including the three reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging banner). Give each a theme-aware pair: a darker readable shade in light theme with the original pinned to dark:, so dark theme is unchanged. ~100 files. The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy kiosk are left untouched. Plain text-white is already theme-aware via the existing index.css .text-white override, so it needed no changes.
400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import type { TFunction } from 'i18next';
|
|
import { Search, Filter, RefreshCw, Droplet, Settings2, Printer as PrinterIcon, Layers, X, Loader2, Clock } from 'lucide-react';
|
|
|
|
import { api } from '../api/client';
|
|
import type { OrcaProfileListResponse, OrcaProfileMeta, Printer } from '../api/client';
|
|
import { Button } from './Button';
|
|
import { FilterDropdown } from '../pages/ProfilesPage';
|
|
import { formatRelativeTime } from '../utils/date';
|
|
|
|
/**
|
|
* Read-only profile browser for the Orca Cloud tab.
|
|
*
|
|
* Visual parity with Bambu Cloud's CloudProfilesView: same filter bar layout,
|
|
* same 3-column grouped list (Filament / Process / Printer), same card
|
|
* styling. Differences vs the Bambu version are removals — no
|
|
* Create / Edit / Duplicate / Delete / Compare / Templates buttons because
|
|
* Orca's `/sync/push` and `/sync/delete` endpoints aren't wired in this
|
|
* shipping cut (planned for a follow-up). The "Owner" filter is also
|
|
* dropped: every profile that lives in a user's Orca Cloud account is
|
|
* user-authored, the system/builtin distinction doesn't apply.
|
|
*/
|
|
|
|
type ProfileType = 'all' | 'filament' | 'printer' | 'process';
|
|
|
|
interface PresetMeta {
|
|
printer: string | null;
|
|
nozzle: string | null;
|
|
layerHeight: string | null;
|
|
filamentType: string | null;
|
|
}
|
|
|
|
// Mirror of ProfilesPage.tsx::extractMetadata. Inlined rather than exported
|
|
// because the patterns are conservative (Bambu printer-name regex etc.) and
|
|
// Orca profile naming follows the same conventions (Orca is a BambuStudio
|
|
// fork; profile names like "Bambu PLA Basic @BBL X1C" carry over verbatim).
|
|
function extractMetadata(name: string): PresetMeta {
|
|
const printerMatch = name.match(/@?\s*(?:BBL\s+)?(?:Bambu\s+Lab\s+)?([XPAH][1-9][A-Z]?(?:\s*(?:Carbon|mini))?|H2D)/i);
|
|
const nozzleMatch = name.match(/(\d+\.?\d*)\s*(?:mm\s*)?nozzle|nozzle\s*(\d+\.?\d*)/i);
|
|
const layerMatch = name.match(/(\d+\.?\d*)mm\s*(?:Standard|Fine|Extra Fine|Draft|Quality)?/i);
|
|
const filamentMatch = name.match(/\b(PLA|PETG|ABS|ASA|TPU|PC|PA|PVA|HIPS|PP|PET(?:-?CF)?|PA(?:-?CF)?|PLA(?:-?CF)?)\b/i);
|
|
return {
|
|
printer: printerMatch ? printerMatch[1].trim() : null,
|
|
nozzle: nozzleMatch ? (nozzleMatch[1] || nozzleMatch[2]) + 'mm' : null,
|
|
layerHeight: layerMatch ? layerMatch[1] + 'mm' : null,
|
|
filamentType: filamentMatch ? filamentMatch[1].toUpperCase() : null,
|
|
};
|
|
}
|
|
|
|
interface OrcaCloudProfilesViewProps {
|
|
settings: OrcaProfileListResponse;
|
|
lastSyncTime?: Date;
|
|
onRefresh: () => void;
|
|
isRefreshing: boolean;
|
|
printers: Printer[];
|
|
t: TFunction;
|
|
}
|
|
|
|
export function OrcaCloudProfilesView({
|
|
settings,
|
|
lastSyncTime,
|
|
onRefresh,
|
|
isRefreshing,
|
|
printers,
|
|
t,
|
|
}: OrcaCloudProfilesViewProps) {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filterType, setFilterType] = useState<ProfileType>('all');
|
|
const [filterPrinter, setFilterPrinter] = useState('all');
|
|
const [filterNozzle, setFilterNozzle] = useState('all');
|
|
const [filterFilament, setFilterFilament] = useState('all');
|
|
const [filterLayerHeight, setFilterLayerHeight] = useState('all');
|
|
const [selectedSetting, setSelectedSetting] = useState<OrcaProfileMeta | null>(null);
|
|
|
|
const allPresetsWithMeta = useMemo(() => {
|
|
const combined = [
|
|
...settings.filament.map(s => ({ ...s, type: 'filament' as const })),
|
|
...settings.printer.map(s => ({ ...s, type: 'printer' as const })),
|
|
...settings.process.map(s => ({ ...s, type: 'process' as const })),
|
|
];
|
|
return combined.map(s => ({ ...s, meta: extractMetadata(s.name) }));
|
|
}, [settings]);
|
|
|
|
const filterOptions = useMemo(() => {
|
|
const nozzles = new Set<string>();
|
|
const filaments = new Set<string>();
|
|
const layerHeights = new Set<string>();
|
|
allPresetsWithMeta.forEach(p => {
|
|
if (p.meta.nozzle) nozzles.add(p.meta.nozzle);
|
|
if (p.meta.filamentType) filaments.add(p.meta.filamentType);
|
|
if (p.meta.layerHeight) layerHeights.add(p.meta.layerHeight);
|
|
});
|
|
return {
|
|
printers: printers.map(p => ({ id: p.id.toString(), name: p.name })),
|
|
nozzles: Array.from(nozzles).sort((a, b) => parseFloat(a) - parseFloat(b)),
|
|
filaments: Array.from(filaments).sort(),
|
|
layerHeights: Array.from(layerHeights).sort((a, b) => parseFloat(a) - parseFloat(b)),
|
|
};
|
|
}, [allPresetsWithMeta, printers]);
|
|
|
|
const selectedPrinterModel = useMemo(() => {
|
|
if (filterPrinter === 'all') return null;
|
|
const printer = printers.find(p => p.id.toString() === filterPrinter);
|
|
return printer?.model || null;
|
|
}, [filterPrinter, printers]);
|
|
|
|
const filteredPresets = useMemo(() => {
|
|
return allPresetsWithMeta
|
|
.filter(s => filterType === 'all' || s.type === filterType)
|
|
.filter(s => {
|
|
if (filterPrinter === 'all' || !selectedPrinterModel) return true;
|
|
const presetPrinter = s.meta.printer?.toLowerCase() || '';
|
|
const configuredModel = selectedPrinterModel.toLowerCase();
|
|
return presetPrinter.includes(configuredModel) || configuredModel.includes(presetPrinter);
|
|
})
|
|
.filter(s => filterNozzle === 'all' || s.meta.nozzle === filterNozzle)
|
|
.filter(s => filterFilament === 'all' || s.meta.filamentType === filterFilament)
|
|
.filter(s => filterLayerHeight === 'all' || s.meta.layerHeight === filterLayerHeight)
|
|
.filter(s => searchQuery === '' || s.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}, [allPresetsWithMeta, filterType, filterPrinter, selectedPrinterModel, filterNozzle, filterFilament, filterLayerHeight, searchQuery]);
|
|
|
|
const clearFilters = () => {
|
|
setFilterType('all');
|
|
setFilterPrinter('all');
|
|
setFilterNozzle('all');
|
|
setFilterFilament('all');
|
|
setFilterLayerHeight('all');
|
|
setSearchQuery('');
|
|
};
|
|
|
|
const hasActiveFilters =
|
|
filterType !== 'all' ||
|
|
filterPrinter !== 'all' ||
|
|
filterNozzle !== 'all' ||
|
|
filterFilament !== 'all' ||
|
|
filterLayerHeight !== 'all' ||
|
|
searchQuery !== '';
|
|
|
|
const totalCount = settings.filament.length + settings.printer.length + settings.process.length;
|
|
|
|
const presetsByType = (type: ProfileType) => filteredPresets.filter(p => p.type === type);
|
|
|
|
return (
|
|
<>
|
|
{/* Search and Filters — mirrors the layout of profiles.cloudView in the
|
|
Bambu Cloud tab so the two tabs feel like the same surface. */}
|
|
<div className="space-y-4 mb-6">
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={t('profiles.cloudView.searchPlaceholder')}
|
|
className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="secondary" onClick={onRefresh} disabled={isRefreshing}>
|
|
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
{t('profiles.cloudView.refresh')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Filter className="w-4 h-4 text-bambu-gray" />
|
|
<FilterDropdown
|
|
label={t('profiles.cloudView.filters.type')}
|
|
value={filterType}
|
|
options={[
|
|
{ value: 'all', label: t('profiles.cloudView.filters.all'), count: totalCount },
|
|
{ value: 'filament', label: t('profiles.cloudView.filters.filament'), count: settings.filament.length },
|
|
{ value: 'printer', label: t('profiles.cloudView.filters.printer'), count: settings.printer.length },
|
|
{ value: 'process', label: t('profiles.cloudView.filters.process'), count: settings.process.length },
|
|
]}
|
|
onChange={(v) => setFilterType(v as ProfileType)}
|
|
/>
|
|
{filterOptions.printers.length > 0 && (
|
|
<FilterDropdown
|
|
label={t('profiles.cloudView.filters.printer')}
|
|
value={filterPrinter}
|
|
options={[
|
|
{ value: 'all', label: t('profiles.cloudView.filters.all') },
|
|
...filterOptions.printers.map(p => ({ value: p.id, label: p.name })),
|
|
]}
|
|
onChange={setFilterPrinter}
|
|
/>
|
|
)}
|
|
{filterOptions.nozzles.length > 0 && (
|
|
<FilterDropdown
|
|
label={t('profiles.cloudView.filters.nozzle')}
|
|
value={filterNozzle}
|
|
options={[
|
|
{ value: 'all', label: t('profiles.cloudView.filters.all') },
|
|
...filterOptions.nozzles.map(n => ({ value: n, label: n })),
|
|
]}
|
|
onChange={setFilterNozzle}
|
|
/>
|
|
)}
|
|
{filterOptions.filaments.length > 0 && (filterType === 'all' || filterType === 'filament') && (
|
|
<FilterDropdown
|
|
label={t('profiles.cloudView.filters.filament')}
|
|
value={filterFilament}
|
|
options={[
|
|
{ value: 'all', label: t('profiles.cloudView.filters.all') },
|
|
...filterOptions.filaments.map(f => ({ value: f, label: f })),
|
|
]}
|
|
onChange={setFilterFilament}
|
|
/>
|
|
)}
|
|
{filterOptions.layerHeights.length > 0 && (filterType === 'all' || filterType === 'process') && (
|
|
<FilterDropdown
|
|
label={t('profiles.cloudView.filters.layer')}
|
|
value={filterLayerHeight}
|
|
options={[
|
|
{ value: 'all', label: t('profiles.cloudView.filters.all') },
|
|
...filterOptions.layerHeights.map(l => ({ value: l, label: l })),
|
|
]}
|
|
onChange={setFilterLayerHeight}
|
|
/>
|
|
)}
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="px-3 py-2 text-sm text-bambu-gray hover:text-white transition-colors"
|
|
>
|
|
{t('profiles.cloudView.clearFilters')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-4 mb-4 text-sm text-bambu-gray">
|
|
{lastSyncTime && (
|
|
<div className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}
|
|
</div>
|
|
)}
|
|
<span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>
|
|
</div>
|
|
|
|
{filteredPresets.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<Layers className="w-12 h-12 text-bambu-gray-dark mx-auto mb-4" />
|
|
<p className="text-bambu-gray">{t('profiles.cloudView.noPresetsFound')}</p>
|
|
{hasActiveFilters && (
|
|
<button onClick={clearFilters} className="mt-2 text-sm text-bambu-green hover:text-bambu-green-light">
|
|
{t('profiles.cloudView.clearFilters')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<PresetColumn
|
|
icon={<Droplet className="w-4 h-4 text-amber-600 dark:text-amber-400" />}
|
|
title={t('profiles.cloudView.columns.filament')}
|
|
presets={presetsByType('filament')}
|
|
emptyText={t('profiles.cloudView.noFilamentPresets')}
|
|
onSelect={setSelectedSetting}
|
|
/>
|
|
<PresetColumn
|
|
icon={<Settings2 className="w-4 h-4 text-blue-600 dark:text-blue-400" />}
|
|
title={t('profiles.cloudView.columns.process')}
|
|
presets={presetsByType('process')}
|
|
emptyText={t('profiles.cloudView.noProcessPresets')}
|
|
onSelect={setSelectedSetting}
|
|
/>
|
|
<PresetColumn
|
|
icon={<PrinterIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" />}
|
|
title={t('profiles.cloudView.columns.printer')}
|
|
presets={presetsByType('printer')}
|
|
emptyText={t('profiles.cloudView.noPrinterPresets')}
|
|
onSelect={setSelectedSetting}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedSetting && (
|
|
<OrcaPresetDetailModal
|
|
setting={selectedSetting}
|
|
onClose={() => setSelectedSetting(null)}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface PresetColumnProps {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
presets: (OrcaProfileMeta & { meta: PresetMeta })[];
|
|
emptyText: string;
|
|
onSelect: (preset: OrcaProfileMeta) => void;
|
|
}
|
|
|
|
function PresetColumn({ icon, title, presets, emptyText, onSelect }: PresetColumnProps) {
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3 px-1">
|
|
{icon}
|
|
<h3 className="text-sm font-medium text-bambu-gray">{title}</h3>
|
|
<span className="text-xs text-bambu-gray-dark">({presets.length})</span>
|
|
</div>
|
|
<div className="space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1">
|
|
{presets.length === 0 ? (
|
|
<p className="text-xs text-bambu-gray-dark px-3 py-2">{emptyText}</p>
|
|
) : (
|
|
presets.map((preset) => (
|
|
<PresetCard key={preset.setting_id} preset={preset} onClick={() => onSelect(preset)} />
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PresetCard({
|
|
preset,
|
|
onClick,
|
|
}: {
|
|
preset: OrcaProfileMeta & { meta: PresetMeta };
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="w-full text-left px-3 py-2 rounded bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-white text-sm truncate flex-1" title={preset.name}>
|
|
{preset.name}
|
|
</span>
|
|
{preset.meta.filamentType && preset.type === 'filament' && (
|
|
<span className="text-xs text-bambu-gray whitespace-nowrap">{preset.meta.filamentType}</span>
|
|
)}
|
|
{preset.meta.layerHeight && preset.type === 'process' && (
|
|
<span className="text-xs text-bambu-gray whitespace-nowrap">{preset.meta.layerHeight}</span>
|
|
)}
|
|
{preset.meta.printer && (
|
|
<span className="text-xs text-bambu-gray whitespace-nowrap">{preset.meta.printer}</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface OrcaPresetDetailModalProps {
|
|
setting: OrcaProfileMeta;
|
|
onClose: () => void;
|
|
t: TFunction;
|
|
}
|
|
|
|
function OrcaPresetDetailModal({ setting, onClose, t }: OrcaPresetDetailModalProps) {
|
|
const { data: detail, isLoading, error } = useQuery({
|
|
queryKey: ['orcaCloudProfileDetail', setting.setting_id],
|
|
queryFn: () => api.orcaCloudGetProfile(setting.setting_id),
|
|
retry: false,
|
|
});
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
|
<div
|
|
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-white">{setting.name}</h2>
|
|
<p className="text-xs text-bambu-gray mt-0.5">{setting.type}</p>
|
|
</div>
|
|
<button onClick={onClose} className="text-bambu-gray hover:text-white p-1">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
|
</div>
|
|
) : error ? (
|
|
<p className="text-center text-bambu-gray py-16">{(error as Error).message}</p>
|
|
) : detail ? (
|
|
<pre className="text-xs font-mono text-bambu-gray bg-bambu-dark p-3 rounded overflow-x-auto whitespace-pre">
|
|
{JSON.stringify(detail.setting, null, 2)}
|
|
</pre>
|
|
) : (
|
|
<p className="text-center text-bambu-gray py-16">{t('profiles.cloudView.noPresetsFound')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|