feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy

Reads, lists, and slices with profiles from a user's Orca Cloud account
  (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing
  Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub /
  email+password); password defaults. Paste-flow PKCE because Orca's
  Supabase project only allowlists localhost redirect_to — open feature
  request at OrcaSlicer/OrcaSlicer#14028.

  Surfaces:
  - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same
    rich layout (search + 5 filter dropdowns + 3-column grouped grid +
    read-only detail modal)
  - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud >
    standard); separate status banner per cloud; metadata-aware pre-pick
    scores Orca filaments above local (Orca's sync_pull returns full
    content inline so filament_type / filament_colour come for free, no
    per-setting fetch rate-limit dance)
  - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed
    orca_<UUID> to match local_/builtin_); generic Bambu filament-ID
    derivation from parsed material (printer firmware can't grok Orca
    UUIDs); slot mapping persists preset_source='orca_cloud'
  - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the
    cloud preset list via Promise.allSettled (OrcaProfileMeta is
    structurally identical to SlicerSetting)

  Backend:
  - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange /
    single-use refresh rotation / get_user_info / list_profiles via the
    bare /sync/pull bootstrap path
  - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish,
    auth/password, status, logout, profiles, profiles/{id}); router-level
    _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers
    (SpoolBuddy kiosk) properly resolve their owner User; just-in-time
    refresh with atomic persist-before-API-call
  - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu
    Cloud fetcher (status vocabulary, 5min cache, permission shortcut);
    _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains
    orca_cloud + orca_cloud_status
  - services/preset_resolver.py: PresetRef.source extended with
    "orca_cloud"; _resolve_orca_cloud walks list + filters
  - 8 columns on users table for tokens (5 persistent) + transient PKCE
    handshake state with 10-min TTL (3); dialect-branched DATETIME /
    TIMESTAMP; auth-disabled mode falls back to Settings table
  - orca_cloud:auth permission folded into can_access_cloud API-key scope
    (same trust dimension)
This commit is contained in:
maziggy
2026-06-04 15:50:44 +02:00
parent 764eb58540
commit 18d534c945
45 changed files with 4891 additions and 630 deletions
+127 -33
View File
@@ -266,6 +266,17 @@ export function ConfigureAmsSlotModal({
retry: false,
});
// Orca Cloud filament profiles, same shape as Bambu Cloud's. Each query
// is independent — the picker degrades gracefully if Orca Cloud isn't
// connected (no entries surface, no error banner because we don't want
// to nag users who deliberately only use Bambu Cloud).
const { data: orcaCloudList } = useQuery({
queryKey: ['orcaCloudProfilesForAmsSlot'],
queryFn: () => api.orcaCloudListProfiles(),
enabled: isOpen,
retry: false,
});
// Fetch local presets
const { data: localPresets, isLoading: localLoading } = useQuery({
queryKey: ['localPresets'],
@@ -301,9 +312,23 @@ export function ConfigureAmsSlotModal({
mutationFn: async () => {
if (!selectedPresetId) throw new Error('No filament preset selected');
// Determine preset source
// Determine preset source. Orca detection is done via setting_id
// lookup as well as the ``orca_`` prefix because the saved-preset
// pre-population on modal open (the useEffect at the top of this
// component) writes ``slotInfo.savedPresetId`` verbatim — which for a
// historical Orca save would be the raw UUID, no prefix. Falling
// through to the cloud lookup in that case would have thrown
// "Selected preset not found".
const isLocal = selectedPresetId.startsWith('local_');
const isBuiltin = selectedPresetId.startsWith('builtin_');
const orcaSettingId = selectedPresetId.startsWith('orca_')
? selectedPresetId.replace('orca_', '')
: selectedPresetId;
const orcaPresetCandidate = (!isLocal && !isBuiltin)
? orcaCloudList?.filament.find(p => p.setting_id === orcaSettingId)
: undefined;
const isOrca = !!orcaPresetCandidate;
const orcaPreset = orcaPresetCandidate ?? null;
const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;
const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null;
const localPreset = isLocal
@@ -313,17 +338,24 @@ export function ConfigureAmsSlotModal({
? builtinFilaments?.find(b => b.filament_id === builtinFilamentId)
: null;
// Get the selected cloud preset details (null for local/builtin presets)
const selectedPreset = (!isLocal && !isBuiltin)
// Get the selected cloud preset details (null for local/builtin/orca presets)
const selectedPreset = (!isLocal && !isBuiltin && !isOrca)
? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)
: null;
if (!isLocal && !isBuiltin && !selectedPreset) throw new Error('Selected preset not found');
if (!isLocal && !isBuiltin && !isOrca && !selectedPreset) throw new Error('Selected preset not found');
if (isLocal && !localPreset) throw new Error('Selected local preset not found');
if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found');
if (isOrca && !orcaPreset) throw new Error('Selected Orca Cloud preset not found');
// Parse the preset name for filament info
const presetName = isLocal ? localPreset!.name : isBuiltin ? builtinPreset!.name : selectedPreset!.name;
const presetName = isLocal
? localPreset!.name
: isBuiltin
? builtinPreset!.name
: isOrca
? orcaPreset!.name
: selectedPreset!.name;
const parsed = parsePresetName(presetName);
// Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
@@ -342,21 +374,27 @@ export function ConfigureAmsSlotModal({
// Prefer this over stored filament_type which may have been parsed with old logic.
const parsedMat = parsed.material.toUpperCase();
// Generic Bambu filament-ID map used to derive a ``tray_info_idx`` for
// presets that don't carry a Bambu setting_id of their own (local
// imports and Orca Cloud sync both fall in this bucket). The printer's
// firmware needs SOMETHING in tray_info_idx to recognize the filament
// type for HMS / drying / colour-matching; the closest generic Bambu
// filament for the parsed material is the right choice.
const GENERIC_IDS: Record<string, string> = {
'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
'ABS': 'GFB99', 'ASA': 'GFB98',
'PC': 'GFC99',
'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
'TPU': 'GFU99',
'PVA': 'GFS99', 'HIPS': 'GFS98',
'PE': 'GFP99', 'PP': 'GFP97',
};
if (isLocal) {
// Local presets have no Bambu Cloud setting_id, but need a valid
// tray_info_idx for the printer to recognize the filament type.
// Map the material type to the closest generic Bambu filament ID.
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '').toUpperCase();
const GENERIC_IDS: Record<string, string> = {
'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
'ABS': 'GFB99', 'ASA': 'GFB98',
'PC': 'GFC99',
'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
'TPU': 'GFU99',
'PVA': 'GFS99', 'HIPS': 'GFS98',
'PE': 'GFP99', 'PP': 'GFP97',
};
// Try exact match first, then base material (strip suffixes like "-CF", "+", " HF")
trayInfoIdx = GENERIC_IDS[material]
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
@@ -364,6 +402,18 @@ export function ConfigureAmsSlotModal({
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|| '';
settingId = '';
} else if (isOrca) {
// Orca Cloud presets have a UUID setting_id that Bambu printers can't
// resolve; treat them like local imports — derive a generic tray_info
// _idx from the parsed material, leave settingId empty so the slicer
// doesn't get a foreign cloud ID it can't look up.
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase();
trayInfoIdx = GENERIC_IDS[material]
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|| GENERIC_IDS[material.replace(/\+$/, '')]
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|| '';
settingId = '';
} else if (isBuiltin) {
// Built-in presets use the filament_id directly as tray_info_idx
trayInfoIdx = builtinFilamentId!;
@@ -392,7 +442,7 @@ export function ConfigureAmsSlotModal({
let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;
let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
if (!isLocal || isBuiltin || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
if (!isLocal || isBuiltin || isOrca || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
// Fall back to material-based defaults (prefer parsed material for "Support for" handling)
const material = (isLocal
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '')
@@ -431,7 +481,9 @@ export function ConfigureAmsSlotModal({
// patterns correctly) over stored filament_type which may have been parsed with old logic.
const trayType = isLocal
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || 'PLA')
: (parsed.material || 'PLA');
: isOrca
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || 'PLA')
: (parsed.material || 'PLA');
// Configure the slot via MQTT
const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
@@ -454,8 +506,20 @@ export function ConfigureAmsSlotModal({
// Save the preset mapping so we can display the correct name in the UI
// This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
// which can't be resolved to a name via the filamentInfo API
const mappingPresetId = isLocal ? `local_${localId}` : isBuiltin ? `builtin_${builtinFilamentId}` : selectedPresetId;
const mappingSource = isLocal ? 'local' : isBuiltin ? 'builtin' : 'cloud';
const mappingPresetId = isLocal
? `local_${localId}`
: isBuiltin
? `builtin_${builtinFilamentId}`
: isOrca
? selectedPresetId
: selectedPresetId;
const mappingSource = isLocal
? 'local'
: isBuiltin
? 'builtin'
: isOrca
? 'orca_cloud'
: 'cloud';
try {
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
} catch (e) {
@@ -491,24 +555,45 @@ export function ConfigureAmsSlotModal({
},
});
// Unified preset item for the list (cloud + local + builtin fallback)
type PresetItem = { id: string; name: string; source: 'cloud' | 'local' | 'builtin'; isUser: boolean };
// Unified preset item for the list (orca_cloud + cloud + local + builtin fallback)
type PresetItem = { id: string; name: string; source: 'orca_cloud' | 'cloud' | 'local' | 'builtin'; isUser: boolean };
// Filter filament presets based on search (merged cloud + local + builtin)
// Filter filament presets based on search (merged orca_cloud + cloud + local + builtin)
const filteredPresets = useMemo(() => {
const query = searchQuery.toLowerCase();
const items: PresetItem[] = [];
// Collect IDs already covered by cloud and local to avoid duplicates in fallback
// Collect IDs already covered by higher-priority tiers to avoid duplicates
const coveredIds = new Set<string>();
// Currently-configured preset should always be shown (bypass model filter)
const savedId = slotInfo.savedPresetId;
const trayIdx = slotInfo.trayInfoIdx;
// 0. Orca Cloud filament presets — surfaced first because the user
// explicitly opted into Orca sync; their picks should outrank Bambu Cloud
// presets of the same name. IDs are prefixed ``orca_`` so the configure
// flow can detect "this is an Orca preset" via a cheap string check
// (mirrors the ``local_`` / ``builtin_`` prefix convention already in use).
if (orcaCloudList?.filament) {
for (const op of orcaCloudList.filament) {
const orcaId = `orca_${op.setting_id}`;
coveredIds.add(op.setting_id);
coveredIds.add(orcaId);
if (query && !op.name.toLowerCase().includes(query)) continue;
if (printerModel) {
const presetModel = extractPresetModel(op.name);
if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue;
}
// All Orca Cloud profiles are user-authored, so isUser is always true.
items.push({ id: orcaId, name: op.name, source: 'orca_cloud', isUser: true });
}
}
// 1. Cloud presets
if (cloudSettings?.filament) {
for (const cp of cloudSettings.filament) {
if (coveredIds.has(cp.setting_id)) continue;
coveredIds.add(cp.setting_id);
// Keep preset if it matches the slot's saved mapping or current tray_info_idx
const isSavedPreset = savedId === cp.setting_id;
@@ -550,21 +635,24 @@ export function ConfigureAmsSlotModal({
}
}
// Sort: cloud user presets first, then cloud built-in, then local, then builtin fallback
// Sort: orca_cloud first (user-curated), then cloud user presets, then
// cloud built-in, then local, then builtin fallback
return items.sort((a, b) => {
const sourceOrder = { cloud: 0, local: 1, builtin: 2 };
const sourceOrder = { orca_cloud: 0, cloud: 1, local: 2, builtin: 3 };
if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source];
if (a.isUser && !b.isUser) return -1;
if (!a.isUser && b.isUser) return 1;
return a.name.localeCompare(b.name);
});
}, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
}, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
// Get full preset name for K profile filtering (brand + material, without printer suffix)
const selectedPresetInfo = useMemo(() => {
if (!selectedPresetId) return null;
// Resolve the name from cloud, local, or builtin presets
// Resolve the name from orca, cloud, local, or builtin presets. The
// Orca branch tolerates both ``orca_<UUID>`` and a bare UUID — see the
// configure-mutation comment for why the raw UUID also reaches us.
let presetName: string | null = null;
if (selectedPresetId.startsWith('local_')) {
const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
@@ -574,11 +662,17 @@ export function ConfigureAmsSlotModal({
const filamentId = selectedPresetId.replace('builtin_', '');
const bf = builtinFilaments?.find(b => b.filament_id === filamentId);
presetName = bf?.name || null;
} else if (cloudSettings?.filament) {
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
presetName = cp?.name || null;
} else {
// No cloud settings available
const orcaCandidateId = selectedPresetId.startsWith('orca_')
? selectedPresetId.replace('orca_', '')
: selectedPresetId;
const op = orcaCloudList?.filament.find(p => p.setting_id === orcaCandidateId);
if (op) {
presetName = op.name;
} else if (cloudSettings?.filament) {
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
presetName = cp?.name || null;
}
}
if (!presetName) {
return null;
@@ -597,7 +691,7 @@ export function ConfigureAmsSlotModal({
material: parsed.material,
brand: parsed.brand,
};
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments]);
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament]);
// For backwards compatibility with the label
const selectedMaterial = selectedPresetInfo?.fullName || '';
@@ -0,0 +1,399 @@
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-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-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-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>
);
}
+515
View File
@@ -0,0 +1,515 @@
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Cloud, ExternalLink, LogOut, Loader2, AlertCircle, AlertTriangle, Check, Mail, ArrowLeft } from 'lucide-react';
import { api } from '../api/client';
import type { OrcaOAuthProvider } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { OrcaCloudProfilesView } from './OrcaCloudProfilesView';
/**
* Orca Cloud profile sync tab.
*
* Auth uses a paste-based PKCE handshake: backend generates the verifier and
* authorize URL, the user opens it in a new tab and signs in, the browser
* redirects to ``http://localhost:41172/callback`` (which fails to load since
* Bambuddy isn't on the user's localhost), and the user copies the URL from
* their address bar back into the paste textarea below. The backend extracts
* the code, validates state for CSRF, and exchanges for tokens.
*
* See OrcaSlicer/OrcaSlicer#14028 for the open feature request asking
* SoftFever to broaden the Supabase redirect_to allowlist so we could ship
* a clean OAuth callback instead.
*/
export function OrcaCloudView() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const canManage = hasPermission('orca_cloud:auth');
// Paste-flow local state: once the user clicks an OAuth provider, we hold
// the returned auth_url so the same URL stays clickable while they go
// fetch the callback URL from their browser. ``mode`` drives which
// sub-form is showing: picker → OAuth paste-flow → email/password form.
const [mode, setMode] = useState<'picker' | 'paste' | 'password'>('picker');
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [pastedUrl, setPastedUrl] = useState('');
const [pasteError, setPasteError] = useState<string | null>(null);
const [passwordEmail, setPasswordEmail] = useState('');
const [passwordValue, setPasswordValue] = useState('');
const [passwordError, setPasswordError] = useState<string | null>(null);
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['orcaCloudStatus'],
queryFn: api.orcaCloudStatus,
});
const connected = !!status?.connected;
const {
data: profilesData,
isLoading: profilesLoading,
refetch: refetchProfiles,
isRefetching: profilesRefetching,
error: profilesError,
dataUpdatedAt: profilesUpdatedAt,
} = useQuery({
queryKey: ['orcaCloudProfiles'],
queryFn: api.orcaCloudListProfiles,
enabled: connected,
retry: false,
staleTime: 1000 * 60 * 5,
});
// Configured Bambuddy printers — fed into the profile-view's printer
// filter dropdown so the user can narrow profiles to a specific printer
// model. Same usage as the Bambu Cloud tab.
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
enabled: connected,
});
const [lastSyncTime, setLastSyncTime] = useState<Date | undefined>();
useEffect(() => {
if (profilesUpdatedAt) setLastSyncTime(new Date(profilesUpdatedAt));
}, [profilesUpdatedAt]);
const startAuthMutation = useMutation({
mutationFn: (provider: OrcaOAuthProvider) => api.orcaCloudStartAuth(provider),
onSuccess: (data) => {
setAuthUrl(data.auth_url);
setPastedUrl('');
setPasteError(null);
setMode('paste');
// Open in a new tab so the user can keep Bambuddy open in their
// current tab while they sign in.
window.open(data.auth_url, '_blank', 'noopener,noreferrer');
},
onError: (err: Error) => {
showToast(err.message || t('profiles.orcaCloud.errors.startFailed'), 'error');
},
});
const finishAuthMutation = useMutation({
mutationFn: (url: string) => api.orcaCloudFinishAuth(url),
onSuccess: (data) => {
setAuthUrl(null);
setPastedUrl('');
setPasteError(null);
setMode('picker');
queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' }));
},
onError: (err: Error) => {
// Surface the backend's error message in the paste-error slot so the
// user can fix the input (rather than a transient toast they might miss).
setPasteError(err.message || t('profiles.orcaCloud.errors.finishFailed'));
},
});
const passwordLoginMutation = useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
api.orcaCloudPasswordLogin(email, password),
onSuccess: (data) => {
setPasswordEmail('');
setPasswordValue('');
setPasswordError(null);
setMode('picker');
queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' }));
},
onError: (err: Error) => {
setPasswordError(err.message || t('profiles.orcaCloud.errors.passwordFailed'));
},
});
const logoutMutation = useMutation({
mutationFn: api.orcaCloudLogout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
queryClient.removeQueries({ queryKey: ['orcaCloudProfiles'] });
showToast(t('profiles.orcaCloud.toast.disconnected'));
},
});
const handleSubmitPaste = (e: React.FormEvent) => {
e.preventDefault();
setPasteError(null);
const trimmed = pastedUrl.trim();
if (!trimmed) {
setPasteError(t('profiles.orcaCloud.errors.emptyPaste'));
return;
}
if (!trimmed.includes('code=')) {
setPasteError(t('profiles.orcaCloud.errors.noCode'));
return;
}
finishAuthMutation.mutate(trimmed);
};
const handleSubmitPassword = (e: React.FormEvent) => {
e.preventDefault();
setPasswordError(null);
const email = passwordEmail.trim();
if (!email || !passwordValue) {
setPasswordError(t('profiles.orcaCloud.errors.passwordEmpty'));
return;
}
passwordLoginMutation.mutate({ email, password: passwordValue });
};
const resetToPicker = () => {
setMode('picker');
setAuthUrl(null);
setPastedUrl('');
setPasteError(null);
setPasswordEmail('');
setPasswordValue('');
setPasswordError(null);
};
if (statusLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
return (
<div>
{connected && (
<div className="flex items-center justify-between p-3 mb-6 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
<span className="text-sm text-bambu-gray">
{t('profiles.orcaCloud.connectedAs')}{' '}
<span className="text-white">{status?.email}</span>
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending || !canManage}
title={!canManage ? t('profiles.orcaCloud.noLogoutPermission') : undefined}
>
<LogOut className="w-4 h-4" />
{t('profiles.orcaCloud.logout')}
</Button>
</div>
)}
{!connected ? (
<ConnectFlow
mode={mode}
authUrl={authUrl}
pastedUrl={pastedUrl}
setPastedUrl={setPastedUrl}
pasteError={pasteError}
passwordEmail={passwordEmail}
setPasswordEmail={setPasswordEmail}
passwordValue={passwordValue}
setPasswordValue={setPasswordValue}
passwordError={passwordError}
onPickProvider={(provider) => startAuthMutation.mutate(provider)}
onPickPassword={() => {
setMode('password');
setPasswordError(null);
}}
onSubmitPaste={handleSubmitPaste}
onSubmitPassword={handleSubmitPassword}
onBack={resetToPicker}
isStarting={startAuthMutation.isPending}
isFinishing={finishAuthMutation.isPending}
isPasswordLoading={passwordLoginMutation.isPending}
canManage={canManage}
t={t}
/>
) : profilesLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : profilesError ? (
<div className="text-center py-16">
<p className="text-bambu-gray mb-4">{(profilesError as Error).message}</p>
<Button onClick={() => refetchProfiles()}>{t('profiles.orcaCloud.retry')}</Button>
</div>
) : profilesData ? (
<OrcaCloudProfilesView
settings={profilesData}
lastSyncTime={lastSyncTime}
onRefresh={() => refetchProfiles()}
isRefreshing={profilesRefetching}
printers={printers}
t={t}
/>
) : null}
</div>
);
}
interface ConnectFlowProps {
mode: 'picker' | 'paste' | 'password';
authUrl: string | null;
pastedUrl: string;
setPastedUrl: (v: string) => void;
pasteError: string | null;
passwordEmail: string;
setPasswordEmail: (v: string) => void;
passwordValue: string;
setPasswordValue: (v: string) => void;
passwordError: string | null;
onPickProvider: (provider: OrcaOAuthProvider) => void;
onPickPassword: () => void;
onSubmitPaste: (e: React.FormEvent) => void;
onSubmitPassword: (e: React.FormEvent) => void;
onBack: () => void;
isStarting: boolean;
isFinishing: boolean;
isPasswordLoading: boolean;
canManage: boolean;
t: (key: string, opts?: Record<string, string>) => string;
}
function ConnectFlow(props: ConnectFlowProps) {
if (props.mode === 'paste' && props.authUrl) {
return <PasteCard {...props} authUrl={props.authUrl} />;
}
if (props.mode === 'password') {
return <PasswordCard {...props} />;
}
return <PickerCard {...props} />;
}
function PickerCard({
onPickProvider,
onPickPassword,
isStarting,
canManage,
t,
}: ConnectFlowProps) {
// Orca's web sign-in offers four options: Google, Apple, GitHub (all
// OAuth, paste-flow) and email+password (direct). We mirror that surface
// so users with a non-Google account aren't blocked.
return (
<Card>
<CardContent className="p-8 text-center">
<Cloud className="w-12 h-12 text-bambu-green mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">
{t('profiles.orcaCloud.connect.title')}
</h2>
<p className="text-bambu-gray mb-6 max-w-xl mx-auto">
{t('profiles.orcaCloud.connect.description')}
</p>
<div className="flex flex-col gap-2 max-w-sm mx-auto">
<Button
onClick={onPickPassword}
disabled={isStarting || !canManage}
title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
>
<Mail className="w-4 h-4" />
{t('profiles.orcaCloud.providers.email')}
</Button>
<Button
variant="secondary"
onClick={() => onPickProvider('google')}
disabled={isStarting || !canManage}
title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
>
{isStarting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ExternalLink className="w-4 h-4" />}
{t('profiles.orcaCloud.providers.google')}
</Button>
<Button
variant="secondary"
onClick={() => onPickProvider('github')}
disabled={isStarting || !canManage}
title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
>
<ExternalLink className="w-4 h-4" />
{t('profiles.orcaCloud.providers.github')}
</Button>
<Button
variant="secondary"
onClick={() => onPickProvider('apple')}
disabled={isStarting || !canManage}
title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
>
<ExternalLink className="w-4 h-4" />
{t('profiles.orcaCloud.providers.apple')}
</Button>
</div>
</CardContent>
</Card>
);
}
function PasteCard({
authUrl,
pastedUrl,
setPastedUrl,
pasteError,
onSubmitPaste,
onBack,
isFinishing,
t,
}: ConnectFlowProps & { authUrl: string }) {
return (
<Card>
<CardContent className="p-6">
<button
type="button"
onClick={onBack}
className="text-bambu-gray hover:text-white text-sm flex items-center gap-1 mb-4"
>
<ArrowLeft className="w-4 h-4" />
{t('profiles.orcaCloud.back')}
</button>
<h2 className="text-xl font-bold text-white mb-4">
{t('profiles.orcaCloud.paste.title')}
</h2>
{/* Numbered-step list with prominent visual treatment. Step 2 carries
the critical "the page failing is expected" message inside an
amber callout so users don't read the connection-refused page
as a Bambuddy error. */}
<ol className="space-y-3 mb-6">
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-bambu-dark-tertiary text-white text-sm font-bold flex items-center justify-center">1</span>
<p className="text-base text-white pt-0.5">{t('profiles.orcaCloud.paste.step1')}</p>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-amber-500/20 text-amber-400 text-sm font-bold flex items-center justify-center">2</span>
<div className="flex-1 p-3 bg-amber-500/10 border border-amber-500/40 rounded">
<div className="flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-base text-white font-medium">{t('profiles.orcaCloud.paste.step2')}</p>
</div>
</div>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-bambu-dark-tertiary text-white text-sm font-bold flex items-center justify-center">3</span>
<p className="text-base text-white pt-0.5">{t('profiles.orcaCloud.paste.step3')}</p>
</li>
</ol>
<div className="mb-4 p-3 bg-bambu-dark rounded border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">{t('profiles.orcaCloud.paste.signInUrl')}</p>
<a
href={authUrl}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-green text-sm break-all hover:underline"
>
{authUrl}
</a>
</div>
<form onSubmit={onSubmitPaste}>
<label htmlFor="orca-callback-url" className="block text-sm text-bambu-gray mb-2">
{t('profiles.orcaCloud.paste.label')}
</label>
<textarea
id="orca-callback-url"
value={pastedUrl}
onChange={(e) => setPastedUrl(e.target.value)}
placeholder={t('profiles.orcaCloud.paste.placeholder')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono resize-none focus:outline-none focus:border-bambu-green"
rows={3}
disabled={isFinishing}
/>
{pasteError && (
<p className="mt-2 text-sm text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{pasteError}
</p>
)}
<div className="mt-4 flex items-center gap-3">
<Button type="submit" disabled={isFinishing || !pastedUrl.trim()}>
{isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('profiles.orcaCloud.paste.submit')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
function PasswordCard({
passwordEmail,
setPasswordEmail,
passwordValue,
setPasswordValue,
passwordError,
onSubmitPassword,
onBack,
isPasswordLoading,
t,
}: ConnectFlowProps) {
return (
<Card>
<CardContent className="p-6 max-w-md mx-auto">
<button
type="button"
onClick={onBack}
className="text-bambu-gray hover:text-white text-sm flex items-center gap-1 mb-4"
>
<ArrowLeft className="w-4 h-4" />
{t('profiles.orcaCloud.back')}
</button>
<h2 className="text-xl font-bold text-white mb-4">
{t('profiles.orcaCloud.password.title')}
</h2>
<form onSubmit={onSubmitPassword} className="space-y-4">
<div>
<label htmlFor="orca-password-email" className="block text-sm text-bambu-gray mb-1">
{t('profiles.orcaCloud.password.email')}
</label>
<input
id="orca-password-email"
type="email"
value={passwordEmail}
onChange={(e) => setPasswordEmail(e.target.value)}
placeholder={t('profiles.orcaCloud.password.emailPlaceholder')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
disabled={isPasswordLoading}
autoComplete="email"
/>
</div>
<div>
<label htmlFor="orca-password-value" className="block text-sm text-bambu-gray mb-1">
{t('profiles.orcaCloud.password.password')}
</label>
<input
id="orca-password-value"
type="password"
value={passwordValue}
onChange={(e) => setPasswordValue(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
disabled={isPasswordLoading}
autoComplete="current-password"
/>
</div>
{passwordError && (
<p className="text-sm text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{passwordError}
</p>
)}
<Button type="submit" disabled={isPasswordLoading || !passwordEmail.trim() || !passwordValue}>
{isPasswordLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('profiles.orcaCloud.password.submit')}
</Button>
</form>
</CardContent>
</Card>
);
}
+59 -21
View File
@@ -38,13 +38,15 @@ interface SliceModalProps {
type Slot = 'printer' | 'process' | 'filament';
// SliceModal-specific tier priority: local (imported) → cloud → standard.
// Imported profiles are surfaced first because they're the user's curated
// picks (often colour/type-tagged), cloud is second since names alone can't
// drive metadata-aware match, standard is the bundled fallback. This is
// SliceModal-specific tier priority: orca_cloud → local → cloud → standard.
// Imported (local) profiles are surfaced before Bambu Cloud because they're
// metadata-tagged (Bambu Cloud isn't, by design — see
// `_fetch_cloud_presets`'s rate-limit note). Orca Cloud comes first because
// its sync_pull response inlines metadata too AND represents the user's
// most-recently-curated source. Standard is the bundled fallback. This is
// distinct from the listing endpoint's dedup order and only affects what
// the SliceModal renders / pre-picks.
const SLICE_MODAL_TIER_ORDER = ['local', 'cloud', 'standard'] as const;
const SLICE_MODAL_TIER_ORDER = ['orca_cloud', 'local', 'cloud', 'standard'] as const;
function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
for (const tier of SLICE_MODAL_TIER_ORDER) {
@@ -115,6 +117,7 @@ function pickProcessDefault(
}
const TIER_BONUS: Record<PresetSource, number> = {
orca_cloud: 1.75,
local: 1.5,
cloud: 1.0,
standard: 0.5,
@@ -176,7 +179,7 @@ function fromRefValue(raw: string): PresetRef | null {
if (idx < 0) return null;
const source = raw.slice(0, idx) as PresetSource;
const id = raw.slice(idx + 1);
if (source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
if (source !== 'orca_cloud' && source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
return { source, id };
}
@@ -747,8 +750,9 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
{presetsQuery.data && (
<>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
<div className="flex-1 space-y-2">
<CloudStatusBanner status={presetsQuery.data.cloud_status} cloudName="bambu" />
<CloudStatusBanner status={presetsQuery.data.orca_cloud_status} cloudName="orca" />
</div>
<button
type="button"
@@ -987,34 +991,67 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
);
}
function CloudStatusBanner({ status }: { status: SlicerCloudStatus }) {
function CloudStatusBanner({
status,
cloudName = 'bambu',
}: {
status: SlicerCloudStatus;
cloudName?: 'bambu' | 'orca';
}) {
const { t } = useTranslation();
if (status === 'ok') return null;
// Map each non-ok status to the appropriate icon + tone. None of these are
// hard errors — the user can still slice using local + standard presets,
// so we use info / warn styling rather than error red.
const config: Record<Exclude<SlicerCloudStatus, 'ok'>, { tone: string; icon: typeof Cloud; key: string; fallback: string }> = {
// Same status vocabulary for both Bambu and Orca Cloud — only the
// user-facing text varies. The fallbacks below name each cloud explicitly
// so the banner makes sense without translation when i18n hasn't been
// updated for a new locale.
const messages =
cloudName === 'orca'
? {
not_authenticated: {
key: 'slice.orcaCloud.notAuthenticated',
fallback: 'Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets.',
},
expired: {
key: 'slice.orcaCloud.expired',
fallback: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
},
unreachable: {
key: 'slice.orcaCloud.unreachable',
fallback: 'Orca Cloud is unreachable right now. Other presets still work.',
},
}
: {
not_authenticated: {
key: 'slice.cloud.notAuthenticated',
fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
},
expired: {
key: 'slice.cloud.expired',
fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
},
unreachable: {
key: 'slice.cloud.unreachable',
fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
};
const tones: Record<Exclude<SlicerCloudStatus, 'ok'>, { tone: string; icon: typeof Cloud }> = {
not_authenticated: {
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
icon: Cloud,
key: 'slice.cloud.notAuthenticated',
fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
},
expired: {
tone: 'border-amber-700/40 bg-amber-900/20 text-amber-200',
icon: CloudOff,
key: 'slice.cloud.expired',
fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
},
unreachable: {
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
icon: CloudOff,
key: 'slice.cloud.unreachable',
fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
},
};
const { tone, icon: Icon, key, fallback } = config[status];
const { tone, icon: Icon } = tones[status];
const { key, fallback } = messages[status];
return (
<div className={`flex items-start gap-2 text-xs rounded-md border p-2 ${tone}`} role="status">
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />
@@ -1112,8 +1149,9 @@ function PresetDropdown({
// empty sections collapse out.
const { sections, otherEntries } = useMemo(() => {
const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
{ key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
{ key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
{ key: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
{ key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' },
{ key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
];
const filterByPrinter = slot !== 'printer';
+39 -8
View File
@@ -122,23 +122,51 @@ export function SpoolFormModal({
setRecentColors(loadRecentColors());
}, []);
// Fetch cloud presets and catalog when modal opens
// Fetch cloud presets and catalog when modal opens. Fetches Bambu Cloud
// and Orca Cloud in parallel; merges Orca filaments into ``cloudPresets``
// since ``OrcaProfileMeta`` is structurally identical to ``SlicerSetting``
// (same fields, same semantics). ``cloudAuthenticated`` flips on if either
// cloud is connected — the UI only uses it to gate "no cloud" hints.
useEffect(() => {
// ``cancelled`` gates every state setter so a fetch that resolves AFTER
// the modal closes / unmounts can't fire setState on a torn-down
// component. Without this guard the parallel Promise.allSettled chain
// can still hit ``setLoadingCloudPresets(false)`` in its ``finally``
// after vitest has dismantled the JSDOM window — surfaced as an
// "Unhandled Rejection: window is not defined" in CI runs.
let cancelled = false;
if (isOpen) {
const fetchData = async () => {
setLoadingCloudPresets(true);
try {
const status = await api.getCloudStatus();
setCloudAuthenticated(status.is_authenticated);
if (status.is_authenticated) {
const presets = await api.getFilamentPresets();
setCloudPresets(presets);
}
const [bambuResult, orcaResult] = await Promise.allSettled([
(async () => {
const status = await api.getCloudStatus();
if (!status.is_authenticated) return { connected: false, presets: [] as SlicerSetting[] };
const presets = await api.getFilamentPresets();
return { connected: true, presets };
})(),
(async () => {
const status = await api.orcaCloudStatus();
if (!status.connected) return { connected: false, presets: [] as SlicerSetting[] };
const list = await api.orcaCloudListProfiles();
// OrcaProfileMeta is structurally identical to SlicerSetting.
return { connected: true, presets: list.filament as unknown as SlicerSetting[] };
})(),
]);
if (cancelled) return;
const bambuConnected = bambuResult.status === 'fulfilled' && bambuResult.value.connected;
const orcaConnected = orcaResult.status === 'fulfilled' && orcaResult.value.connected;
const bambuPresets = bambuResult.status === 'fulfilled' ? bambuResult.value.presets : [];
const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
setCloudAuthenticated(bambuConnected || orcaConnected);
setCloudPresets([...bambuPresets, ...orcaPresets]);
} catch (e) {
if (cancelled) return;
console.error('Failed to fetch cloud presets:', e);
setCloudAuthenticated(false);
} finally {
setLoadingCloudPresets(false);
if (!cancelled) setLoadingCloudPresets(false);
}
};
fetchData();
@@ -195,6 +223,9 @@ export function SpoolFormModal({
// "test environment was torn down" errors in vitest. spoolmanMode only
// gates a single fetch (getSpoolCatalog) which is cheap enough to skip
// when the modal opens in Spoolman mode.
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, printersWithCalibrations.length]);