mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user