Files
bambuddy/frontend/src/components/OrcaCloudProfilesView.tsx
T
maziggy 1d344a8536 fix(ui): resolve light-theme low-contrast semantic text app-wide (#1909)
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.
2026-07-07 09:54:10 +02:00

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>
);
}