|
@@ -12,6 +12,9 @@ import {
|
|
|
type UnifiedPresetsResponse,
|
|
type UnifiedPresetsResponse,
|
|
|
} from '../api/client';
|
|
} from '../api/client';
|
|
|
import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
|
|
import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
|
|
|
|
|
+import { PlatePickerModal } from './PlatePickerModal';
|
|
|
|
|
+import type { PlateFilament } from '../types/plates';
|
|
|
|
|
+import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
|
|
|
|
|
|
|
|
export type SliceSource =
|
|
export type SliceSource =
|
|
|
| { kind: 'libraryFile'; id: number; filename: string }
|
|
| { kind: 'libraryFile'; id: number; filename: string }
|
|
@@ -24,10 +27,16 @@ interface SliceModalProps {
|
|
|
|
|
|
|
|
type Slot = 'printer' | 'process' | 'filament';
|
|
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
|
|
|
|
|
+// 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;
|
|
|
|
|
+
|
|
|
function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
|
|
function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
|
|
|
- // Cloud > local > standard. The endpoint already deduplicates by name, so
|
|
|
|
|
- // no name-collision handling needed here — first non-empty tier wins.
|
|
|
|
|
- for (const tier of ['cloud', 'local', 'standard'] as const) {
|
|
|
|
|
|
|
+ for (const tier of SLICE_MODAL_TIER_ORDER) {
|
|
|
const list = by[tier][slot];
|
|
const list = by[tier][slot];
|
|
|
if (list.length > 0) {
|
|
if (list.length > 0) {
|
|
|
return { source: list[0].source, id: list[0].id };
|
|
return { source: list[0].source, id: list[0].id };
|
|
@@ -36,6 +45,48 @@ function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const TIER_BONUS: Record<PresetSource, number> = {
|
|
|
|
|
+ local: 1.5,
|
|
|
|
|
+ cloud: 1.0,
|
|
|
|
|
+ standard: 0.5,
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function pickFilamentForSlot(
|
|
|
|
|
+ by: UnifiedPresetsResponse,
|
|
|
|
|
+ required: { type: string; color: string },
|
|
|
|
|
+): PresetRef | null {
|
|
|
|
|
+ // Score every filament preset against the plate slot's required (type,
|
|
|
|
|
+ // colour) and pick the highest. Mirrors the AMS slot-mapping match in the
|
|
|
|
|
+ // print/schedule modal: type match dominates, exact-colour-match bumps over
|
|
|
|
|
+ // similar-colour-match, and a small per-tier bonus breaks ties so cloud
|
|
|
|
|
+ // user customisations win over standard bundled fallbacks of equal merit.
|
|
|
|
|
+ const reqType = required.type.trim().toUpperCase();
|
|
|
|
|
+ const reqColor = normalizeColorForCompare(required.color);
|
|
|
|
|
+
|
|
|
|
|
+ let best: { ref: PresetRef; score: number } | null = null;
|
|
|
|
|
+ for (const tier of SLICE_MODAL_TIER_ORDER) {
|
|
|
|
|
+ for (const p of by[tier].filament) {
|
|
|
|
|
+ let score = 0;
|
|
|
|
|
+ const presetType = (p.filament_type ?? '').trim().toUpperCase();
|
|
|
|
|
+ const presetColor = normalizeColorForCompare(p.filament_colour ?? '');
|
|
|
|
|
+ if (reqType && presetType && reqType === presetType) score += 10;
|
|
|
|
|
+ if (reqColor && presetColor) {
|
|
|
|
|
+ if (presetColor === reqColor) score += 5;
|
|
|
|
|
+ else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2;
|
|
|
|
|
+ }
|
|
|
|
|
+ score += TIER_BONUS[tier];
|
|
|
|
|
+ if (best == null || score > best.score) {
|
|
|
|
|
+ best = { ref: { source: p.source, id: p.id }, score };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // Fall back to plain priority pick if every preset scored 0+tier (i.e. no
|
|
|
|
|
+ // metadata matched). The fallback is exactly the single-color default —
|
|
|
|
|
+ // first preset in the highest-priority non-empty tier.
|
|
|
|
|
+ if (best == null) return pickDefault(by, 'filament');
|
|
|
|
|
+ return best.ref;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function toRefValue(ref: PresetRef | null): string {
|
|
function toRefValue(ref: PresetRef | null): string {
|
|
|
// The HTML `<select>` value space is flat strings; encode source + id so
|
|
// The HTML `<select>` value space is flat strings; encode source + id so
|
|
|
// the same preset name can live in multiple tiers without collision.
|
|
// the same preset name can live in multiple tiers without collision.
|
|
@@ -58,36 +109,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|
|
|
|
|
|
|
const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
|
|
const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
|
|
|
const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
|
|
const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
|
|
|
- const [filamentPreset, setFilamentPreset] = useState<PresetRef | null>(null);
|
|
|
|
|
|
|
+ // One filament ref per plate slot, in plate order. For STL / single-plate /
|
|
|
|
|
+ // single-color sources this is a one-element array; multi-color 3MFs get one
|
|
|
|
|
+ // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
|
|
|
|
|
+ // each slot from the source plate's required (type, colour).
|
|
|
|
|
+ const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
|
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
|
+ // null = plate not yet picked (or single-plate / non-3MF — picker is skipped
|
|
|
|
|
+ // and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
|
|
|
|
|
+ // the user picks one (or implicitly for single-plate sources).
|
|
|
|
|
+ const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ const platesQuery = useQuery({
|
|
|
|
|
+ queryKey: ['slicePlates', source.kind, source.id],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ if (source.kind === 'libraryFile') {
|
|
|
|
|
+ return api.getLibraryFilePlates(source.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return api.getArchivePlates(source.id);
|
|
|
|
|
+ },
|
|
|
|
|
+ staleTime: 60_000,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const isMultiPlate =
|
|
|
|
|
+ !!platesQuery.data?.is_multi_plate && (platesQuery.data?.plates?.length ?? 0) > 1;
|
|
|
|
|
+ // Single-plate / non-3MF / fetch failure: skip the picker, default to plate 1
|
|
|
|
|
+ // at submit time so the backend's existing default behaviour is preserved.
|
|
|
|
|
+ const needsPlatePicker = isMultiPlate && selectedPlate == null;
|
|
|
|
|
+
|
|
|
|
|
+ // Per-plate filament requirements via the same endpoint the print/schedule
|
|
|
|
|
+ // modal uses. Reusing it here keeps the SliceModal honest with whatever
|
|
|
|
|
+ // logic that endpoint applies (slice_info parsing, future enhancements for
|
|
|
|
|
+ // unsliced project files, dual-nozzle fields, etc.) instead of duplicating
|
|
|
|
|
+ // extraction. plate_id is always sent: single-plate falls through to plate
|
|
|
|
|
+ // 1 server-side; multi-plate uses the user's pick.
|
|
|
|
|
+ const effectivePlateId = selectedPlate ?? 1;
|
|
|
|
|
+ const filamentReqsQuery = useQuery({
|
|
|
|
|
+ queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ if (source.kind === 'libraryFile') {
|
|
|
|
|
+ return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId);
|
|
|
|
|
+ }
|
|
|
|
|
+ return api.getArchiveFilamentRequirements(source.id, effectivePlateId);
|
|
|
|
|
+ },
|
|
|
|
|
+ enabled: !needsPlatePicker,
|
|
|
|
|
+ staleTime: 60_000,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Filament slot list for the active plate. Falls back to one synthetic slot
|
|
|
|
|
+ // for STL/STEP and any "no metadata available" case so the modal still
|
|
|
|
|
+ // works (single dropdown, mono-color slice).
|
|
|
|
|
+ const filamentSlots = useMemo<PlateFilament[]>(() => {
|
|
|
|
|
+ const reqs = filamentReqsQuery.data?.filaments ?? [];
|
|
|
|
|
+ if (reqs.length > 0) return reqs as PlateFilament[];
|
|
|
|
|
+ return [
|
|
|
|
|
+ { slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 },
|
|
|
|
|
+ ];
|
|
|
|
|
+ }, [filamentReqsQuery.data]);
|
|
|
|
|
|
|
|
const presetsQuery = useQuery({
|
|
const presetsQuery = useQuery({
|
|
|
queryKey: ['slicerPresets'],
|
|
queryKey: ['slicerPresets'],
|
|
|
queryFn: () => api.getSlicerPresets(),
|
|
queryFn: () => api.getSlicerPresets(),
|
|
|
staleTime: 60_000,
|
|
staleTime: 60_000,
|
|
|
|
|
+ // Don't fetch presets while the plate picker is on screen — saves a
|
|
|
|
|
+ // round-trip if the user cancels out of the plate step.
|
|
|
|
|
+ enabled: !platesQuery.isLoading && !needsPlatePicker,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Default selection: cloud > local > standard. Runs only on the first
|
|
|
|
|
- // successful load; subsequent re-renders preserve the user's manual choice.
|
|
|
|
|
|
|
+ // Printer / process pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when
|
|
|
|
|
+ // presets first arrive; subsequent re-renders preserve any manual choice.
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!presetsQuery.data) return;
|
|
if (!presetsQuery.data) return;
|
|
|
if (printerPreset == null) setPrinterPreset(pickDefault(presetsQuery.data, 'printer'));
|
|
if (printerPreset == null) setPrinterPreset(pickDefault(presetsQuery.data, 'printer'));
|
|
|
if (processPreset == null) setProcessPreset(pickDefault(presetsQuery.data, 'process'));
|
|
if (processPreset == null) setProcessPreset(pickDefault(presetsQuery.data, 'process'));
|
|
|
- if (filamentPreset == null) setFilamentPreset(pickDefault(presetsQuery.data, 'filament'));
|
|
|
|
|
- // Intentionally exclude state-setters and current selections from deps —
|
|
|
|
|
- // we only want the auto-pick to fire once when data first arrives.
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, [presetsQuery.data]);
|
|
}, [presetsQuery.data]);
|
|
|
|
|
|
|
|
|
|
+ // Filament pre-pick: re-runs whenever the active filament-slot count
|
|
|
|
|
+ // changes (plate selection, single-plate metadata arriving). For each slot
|
|
|
|
|
+ // we score every available filament preset against the slot's required
|
|
|
|
|
+ // (type, colour) and keep the highest match. Slot count mismatch → reset
|
|
|
|
|
+ // and re-pick everything; same length → preserve any user override.
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!presetsQuery.data) return;
|
|
|
|
|
+ const data = presetsQuery.data;
|
|
|
|
|
+ setFilamentPresets((current) => {
|
|
|
|
|
+ if (current.length === filamentSlots.length && current.every((r) => r != null)) {
|
|
|
|
|
+ return current;
|
|
|
|
|
+ }
|
|
|
|
|
+ return filamentSlots.map((slot) =>
|
|
|
|
|
+ pickFilamentForSlot(data, { type: slot.type, color: slot.color }),
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [presetsQuery.data, filamentSlots]);
|
|
|
|
|
+
|
|
|
const enqueueMutation = useMutation({
|
|
const enqueueMutation = useMutation({
|
|
|
mutationFn: async () => {
|
|
mutationFn: async () => {
|
|
|
- if (!printerPreset || !processPreset || !filamentPreset) {
|
|
|
|
|
- throw new Error('All three presets must be selected');
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ !printerPreset ||
|
|
|
|
|
+ !processPreset ||
|
|
|
|
|
+ filamentPresets.length === 0 ||
|
|
|
|
|
+ filamentPresets.some((r) => r == null)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
|
|
|
}
|
|
}
|
|
|
const body = {
|
|
const body = {
|
|
|
printer_preset: printerPreset,
|
|
printer_preset: printerPreset,
|
|
|
process_preset: processPreset,
|
|
process_preset: processPreset,
|
|
|
- filament_preset: filamentPreset,
|
|
|
|
|
|
|
+ // The first slot also goes into the legacy singular field so the
|
|
|
|
|
+ // backend's older callers / clients keep behaving the same — the
|
|
|
|
|
+ // backend validator prefers `filament_presets` when both are set.
|
|
|
|
|
+ filament_preset: filamentPresets[0] as PresetRef,
|
|
|
|
|
+ filament_presets: filamentPresets as PresetRef[],
|
|
|
|
|
+ // Always send a concrete plate number when the source is multi-plate;
|
|
|
|
|
+ // omit otherwise so the backend default applies for STL / single-plate
|
|
|
|
|
+ // 3MF sources where the concept doesn't apply.
|
|
|
|
|
+ ...(selectedPlate != null ? { plate: selectedPlate } : {}),
|
|
|
};
|
|
};
|
|
|
if (source.kind === 'libraryFile') {
|
|
if (source.kind === 'libraryFile') {
|
|
|
return api.sliceLibraryFile(source.id, body);
|
|
return api.sliceLibraryFile(source.id, body);
|
|
@@ -104,9 +241,29 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- const isReady = printerPreset != null && processPreset != null && filamentPreset != null;
|
|
|
|
|
|
|
+ const isReady =
|
|
|
|
|
+ printerPreset != null &&
|
|
|
|
|
+ processPreset != null &&
|
|
|
|
|
+ filamentPresets.length > 0 &&
|
|
|
|
|
+ filamentPresets.every((r) => r != null);
|
|
|
const isEnqueuing = enqueueMutation.isPending;
|
|
const isEnqueuing = enqueueMutation.isPending;
|
|
|
|
|
|
|
|
|
|
+ // Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
|
|
|
|
|
+ // entire flow (matches the existing PlatePickerModal contract used by the
|
|
|
|
|
+ // archive g-code-viewer entry point).
|
|
|
|
|
+ if (needsPlatePicker && platesQuery.data) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <PlatePickerModal
|
|
|
|
|
+ plates={platesQuery.data.plates}
|
|
|
|
|
+ onSelect={(plateIndex) => setSelectedPlate(plateIndex)}
|
|
|
|
|
+ onClose={onClose}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Step 2 (or only step for single-plate / non-3MF / load-failure): preset
|
|
|
|
|
+ // picker. While the plates query is in-flight we still render the shell
|
|
|
|
|
+ // because the presets query is gated on it; the loader covers both.
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
|
@@ -127,6 +284,9 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|
|
</h3>
|
|
</h3>
|
|
|
<p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
|
|
<p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
|
|
|
{source.filename}
|
|
{source.filename}
|
|
|
|
|
+ {selectedPlate != null
|
|
|
|
|
+ ? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}`
|
|
|
|
|
+ : ''}
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
<button
|
|
<button
|
|
@@ -141,7 +301,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|
|
|
|
|
|
|
{/* Body */}
|
|
{/* Body */}
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
|
- {presetsQuery.isLoading && (
|
|
|
|
|
|
|
+ {(platesQuery.isLoading || presetsQuery.isLoading || filamentReqsQuery.isLoading) && (
|
|
|
<div className="flex items-center gap-2 text-bambu-gray text-sm">
|
|
<div className="flex items-center gap-2 text-bambu-gray text-sm">
|
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
|
{t('slice.loadingPresets', 'Loading presets…')}
|
|
{t('slice.loadingPresets', 'Loading presets…')}
|
|
@@ -176,14 +336,34 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
|
|
onChange={setProcessPreset}
|
|
onChange={setProcessPreset}
|
|
|
disabled={isEnqueuing}
|
|
disabled={isEnqueuing}
|
|
|
/>
|
|
/>
|
|
|
- <PresetDropdown
|
|
|
|
|
- label={t('slice.filament', 'Filament profile')}
|
|
|
|
|
- slot="filament"
|
|
|
|
|
- data={presetsQuery.data}
|
|
|
|
|
- value={filamentPreset}
|
|
|
|
|
- onChange={setFilamentPreset}
|
|
|
|
|
- disabled={isEnqueuing}
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ {filamentSlots.map((slot, idx) => (
|
|
|
|
|
+ <PresetDropdown
|
|
|
|
|
+ key={`filament-${idx}`}
|
|
|
|
|
+ label={
|
|
|
|
|
+ filamentSlots.length > 1
|
|
|
|
|
+ ? t('slice.filamentSlot', {
|
|
|
|
|
+ index: idx + 1,
|
|
|
|
|
+ type: slot.type,
|
|
|
|
|
+ defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
|
|
|
|
|
+ })
|
|
|
|
|
+ : t('slice.filament', 'Filament profile')
|
|
|
|
|
+ }
|
|
|
|
|
+ slot="filament"
|
|
|
|
|
+ data={presetsQuery.data}
|
|
|
|
|
+ value={filamentPresets[idx] ?? null}
|
|
|
|
|
+ onChange={(ref) =>
|
|
|
|
|
+ setFilamentPresets((current) => {
|
|
|
|
|
+ const next = current.length === filamentSlots.length
|
|
|
|
|
+ ? [...current]
|
|
|
|
|
+ : filamentSlots.map((_, i) => current[i] ?? null);
|
|
|
|
|
+ next[idx] = ref;
|
|
|
|
|
+ return next;
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ disabled={isEnqueuing}
|
|
|
|
|
+ swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
</>
|
|
</>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
@@ -271,15 +451,22 @@ interface PresetDropdownProps {
|
|
|
value: PresetRef | null;
|
|
value: PresetRef | null;
|
|
|
onChange: (ref: PresetRef | null) => void;
|
|
onChange: (ref: PresetRef | null) => void;
|
|
|
disabled?: boolean;
|
|
disabled?: boolean;
|
|
|
|
|
+ // Optional colour swatch shown next to the label — used for multi-color
|
|
|
|
|
+ // filament slots so the user can see at a glance which slot they're
|
|
|
|
|
+ // configuring against the source 3MF's per-slot colour.
|
|
|
|
|
+ swatchColor?: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function PresetDropdown({ label, slot, data, value, onChange, disabled }: PresetDropdownProps) {
|
|
|
|
|
|
|
+function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchColor }: PresetDropdownProps) {
|
|
|
const { t } = useTranslation();
|
|
const { t } = useTranslation();
|
|
|
|
|
|
|
|
const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => {
|
|
const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => {
|
|
|
|
|
+ // Order matches SLICE_MODAL_TIER_ORDER: imported first, then cloud, then
|
|
|
|
|
+ // standard fallback. Sections with no entries collapse out so a user
|
|
|
|
|
+ // without cloud / local presets only sees the tiers they actually have.
|
|
|
const tiers: { key: keyof UnifiedPresetsResponse; tier: 'cloud' | 'local' | 'standard'; label: string; fallback: string }[] = [
|
|
const tiers: { key: keyof UnifiedPresetsResponse; tier: 'cloud' | 'local' | 'standard'; label: string; fallback: string }[] = [
|
|
|
- { key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
|
|
|
|
|
{ key: 'local', tier: 'local', label: 'slice.tier.local', fallback: 'Imported' },
|
|
{ key: 'local', tier: 'local', label: 'slice.tier.local', fallback: 'Imported' },
|
|
|
|
|
+ { key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
|
|
|
{ key: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
|
|
{ key: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
|
|
|
];
|
|
];
|
|
|
return tiers
|
|
return tiers
|
|
@@ -294,7 +481,16 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled }: Preset
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<label className="block">
|
|
<label className="block">
|
|
|
- <span className="block text-xs text-bambu-gray mb-1">{label}</span>
|
|
|
|
|
|
|
+ <span className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
|
|
|
|
|
+ {swatchColor && (
|
|
|
|
|
+ <span
|
|
|
|
|
+ className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
|
|
|
|
|
+ style={{ backgroundColor: swatchColor || 'transparent' }}
|
|
|
|
|
+ aria-hidden
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ <span>{label}</span>
|
|
|
|
|
+ </span>
|
|
|
<select
|
|
<select
|
|
|
value={toRefValue(value)}
|
|
value={toRefValue(value)}
|
|
|
onChange={(e) => onChange(fromRefValue(e.target.value))}
|
|
onChange={(e) => onChange(fromRefValue(e.target.value))}
|