| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979 |
- import { useState, useEffect, useMemo } from 'react';
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
- import { useTranslation } from 'react-i18next';
- import {
- Cloud,
- LogIn,
- LogOut,
- Loader2,
- Settings2,
- Printer as PrinterIcon,
- Droplet,
- X,
- Key,
- RefreshCw,
- Gauge,
- Pencil,
- Trash2,
- Save,
- AlertTriangle,
- Search,
- Plus,
- Copy,
- Clock,
- Layers,
- Filter,
- ChevronDown,
- ArrowUp,
- Upload,
- Download,
- Sparkles,
- Check,
- AlertCircle,
- Code,
- Sliders,
- List,
- Eye,
- EyeOff,
- GitCompare,
- ArrowRight,
- Equal,
- Minus as MinusIcon,
- Plus as PlusIcon,
- HardDrive,
- } from 'lucide-react';
- import { api } from '../api/client';
- import { parseUTCDate } from '../utils/date';
- import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
- import { Card, CardContent } from '../components/Card';
- import { Button } from '../components/Button';
- import { useToast } from '../contexts/ToastContext';
- import { useAuth } from '../contexts/AuthContext';
- import { KProfilesView } from '../components/KProfilesView';
- import { LocalProfilesView } from '../components/LocalProfilesView';
- type TFunction = (key: string, options?: Record<string, unknown>) => string;
- type ProfileTab = 'cloud' | 'local' | 'kprofiles';
- type LoginStep = 'email' | 'code' | 'token';
- type PresetType = 'all' | 'filament' | 'printer' | 'process';
- // Extract metadata from preset name or inherits field
- function extractMetadata(name: string, inherits?: string): {
- printer: string | null;
- nozzle: string | null;
- layerHeight: string | null;
- filamentType: string | null;
- } {
- const searchIn = `${name} ${inherits || ''}`;
- // Extract printer (e.g., "X1C", "P1S", "A1", "H2D")
- const printerMatch = searchIn.match(/@?\s*(?:BBL\s+)?(?:Bambu\s+Lab\s+)?([XPAH][1-9][A-Z]?(?:\s*(?:Carbon|mini))?|H2D)/i);
- const printer = printerMatch ? printerMatch[1].trim() : null;
- // Extract nozzle size (e.g., "0.4 nozzle", "0.6mm")
- const nozzleMatch = searchIn.match(/(\d+\.?\d*)\s*(?:mm\s*)?nozzle|nozzle\s*(\d+\.?\d*)/i);
- const nozzle = nozzleMatch ? (nozzleMatch[1] || nozzleMatch[2]) + 'mm' : null;
- // Extract layer height (e.g., "0.20mm", "0.08mm Extra Fine")
- const layerMatch = searchIn.match(/(\d+\.?\d*)mm\s*(?:Standard|Fine|Extra Fine|Draft|Quality)?/i);
- const layerHeight = layerMatch ? layerMatch[1] + 'mm' : null;
- // Extract filament type (e.g., "PLA", "PETG", "ABS", "TPU")
- const filamentMatch = searchIn.match(/\b(PLA|PETG|ABS|ASA|TPU|PC|PA|PVA|HIPS|PP|PET(?:-?CF)?|PA(?:-?CF)?|PLA(?:-?CF)?)\b/i);
- const filamentType = filamentMatch ? filamentMatch[1].toUpperCase() : null;
- return { printer, nozzle, layerHeight, filamentType };
- }
- // Check if preset is user-created (editable)
- function isUserPreset(settingId: string): boolean {
- return /^(P[FPM]US|PF\d|PP\d)/.test(settingId);
- }
- // Format relative time
- function formatRelativeTime(dateStr: string, t: TFunction): string {
- const date = parseUTCDate(dateStr);
- if (!date) return '';
- const now = new Date();
- const diffMs = now.getTime() - date.getTime();
- const diffMins = Math.floor(diffMs / 60000);
- const diffHours = Math.floor(diffMs / 3600000);
- const diffDays = Math.floor(diffMs / 86400000);
- if (diffMins < 1) return t('profiles.time.justNow');
- if (diffMins < 60) return t('profiles.time.minsAgo', { count: diffMins });
- if (diffHours < 24) return t('profiles.time.hoursAgo', { count: diffHours });
- if (diffDays < 7) return t('profiles.time.daysAgo', { count: diffDays });
- return date.toLocaleDateString();
- }
- // ============================================================================
- // LOGIN FORM
- // ============================================================================
- function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
- const { showToast } = useToast();
- const [step, setStep] = useState<LoginStep>('email');
- const [email, setEmail] = useState('');
- const [password, setPassword] = useState('');
- const [code, setCode] = useState('');
- const [token, setToken] = useState('');
- const [region, setRegion] = useState('global');
- const [verificationType, setVerificationType] = useState<'email' | 'totp' | null>(null);
- const [tfaKey, setTfaKey] = useState<string | null>(null);
- const loginMutation = useMutation({
- mutationFn: () => api.cloudLogin(email, password, region),
- onSuccess: (result) => {
- if (result.success) {
- showToast(t('profiles.login.toast.loggedIn'));
- onSuccess();
- } else if (result.needs_verification) {
- setVerificationType(result.verification_type || 'email');
- setTfaKey(result.tfa_key || null);
- if (result.verification_type === 'totp') {
- showToast(t('profiles.login.toast.enterTotp'));
- } else {
- showToast(t('profiles.login.toast.codeSent'));
- }
- setStep('code');
- } else {
- showToast(result.message, 'error');
- }
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const verifyMutation = useMutation({
- mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined),
- onSuccess: (result) => {
- if (result.success) {
- showToast(t('profiles.login.toast.loggedIn'));
- onSuccess();
- } else {
- showToast(result.message, 'error');
- }
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const tokenMutation = useMutation({
- mutationFn: () => api.cloudSetToken(token),
- onSuccess: () => {
- showToast(t('profiles.login.toast.tokenSet'));
- onSuccess();
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (step === 'email') loginMutation.mutate();
- else if (step === 'code') verifyMutation.mutate();
- else if (step === 'token') tokenMutation.mutate();
- };
- const isPending = loginMutation.isPending || verifyMutation.isPending || tokenMutation.isPending;
- return (
- <Card className="max-w-md mx-auto">
- <CardContent>
- <div className="text-center mb-6">
- <div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-bambu-green/20 mb-3">
- <Cloud className="w-6 h-6 text-bambu-green" />
- </div>
- <h2 className="text-xl font-semibold text-white">{t('profiles.login.title')}</h2>
- <p className="text-sm text-bambu-gray mt-1">{t('profiles.login.subtitle')}</p>
- </div>
- <form onSubmit={handleSubmit} className="space-y-4">
- {step === 'email' && (
- <>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.email')}</label>
- <input
- type="email"
- value={email}
- onChange={(e) => setEmail(e.target.value)}
- className="w-full px-3 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"
- placeholder="your@email.com"
- required
- />
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.password')}</label>
- <input
- type="password"
- value={password}
- onChange={(e) => setPassword(e.target.value)}
- className="w-full px-3 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"
- placeholder="••••••••"
- required
- />
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.region')}</label>
- <select
- value={region}
- onChange={(e) => setRegion(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- >
- <option value="global">{t('profiles.login.regionGlobal')}</option>
- <option value="china">{t('profiles.login.regionChina')}</option>
- </select>
- </div>
- </>
- )}
- {step === 'code' && (
- <div>
- <label className="block text-sm text-bambu-gray mb-1">
- {verificationType === 'totp' ? t('profiles.login.totpCode') : t('profiles.login.verificationCode')}
- </label>
- <p className="text-xs text-bambu-gray mb-2">
- {verificationType === 'totp'
- ? t('profiles.login.enterTotpHint')
- : t('profiles.login.checkEmail', { email })}
- </p>
- <input
- type="text"
- value={code}
- onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
- className="w-full px-3 py-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-center text-2xl tracking-widest font-mono focus:border-bambu-green focus:outline-none"
- placeholder="000000"
- maxLength={6}
- required
- />
- </div>
- )}
- {step === 'token' && (
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.accessToken')}</label>
- <p className="text-xs text-bambu-gray mb-2">{t('profiles.login.accessTokenHint')}</p>
- <textarea
- value={token}
- onChange={(e) => setToken(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none resize-none"
- placeholder="eyJ..."
- rows={4}
- required
- />
- </div>
- )}
- <div className="flex gap-2">
- {step === 'code' && (
- <Button type="button" variant="secondary" onClick={() => setStep('email')} className="flex-1">
- {t('profiles.login.back')}
- </Button>
- )}
- <Button type="submit" disabled={isPending} className="flex-1">
- {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <LogIn className="w-4 h-4" />}
- {step === 'email' ? t('profiles.login.loginButton') : step === 'code' ? t('profiles.login.verifyButton') : t('profiles.login.setTokenButton')}
- </Button>
- </div>
- {step === 'email' && (
- <div className="pt-4 border-t border-bambu-dark-tertiary">
- <button
- type="button"
- onClick={() => setStep('token')}
- className="text-sm text-bambu-gray hover:text-white flex items-center gap-2 transition-colors"
- >
- <Key className="w-4 h-4" />
- {t('profiles.login.useToken')}
- </button>
- </div>
- )}
- {step === 'token' && (
- <div className="pt-4 border-t border-bambu-dark-tertiary">
- <button
- type="button"
- onClick={() => setStep('email')}
- className="text-sm text-bambu-gray hover:text-white flex items-center gap-2 transition-colors"
- >
- <LogIn className="w-4 h-4" />
- {t('profiles.login.useEmail')}
- </button>
- </div>
- )}
- </form>
- </CardContent>
- </Card>
- );
- }
- // ============================================================================
- // FILTER DROPDOWN
- // ============================================================================
- function FilterDropdown({
- label,
- value,
- options,
- onChange,
- }: {
- label: string;
- value: string;
- options: { value: string; label: string; count?: number }[];
- onChange: (value: string) => void;
- }) {
- const [isOpen, setIsOpen] = useState(false);
- const selectedOption = options.find(o => o.value === value);
- return (
- <div className="relative">
- <button
- onClick={() => setIsOpen(!isOpen)}
- className="flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white hover:border-bambu-gray-dark transition-colors"
- >
- <span className="text-bambu-gray">{label}:</span>
- <span>{selectedOption?.label || 'All'}</span>
- <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isOpen ? 'rotate-180' : ''}`} />
- </button>
- {isOpen && (
- <>
- <div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
- <div className="absolute top-full left-0 mt-1 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 py-1 max-h-60 overflow-y-auto">
- {options.map((option) => (
- <button
- key={option.value}
- onClick={() => { onChange(option.value); setIsOpen(false); }}
- className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-bambu-dark-tertiary transition-colors ${
- value === option.value ? 'text-bambu-green' : 'text-white'
- }`}
- >
- <span>{option.label}</span>
- {option.count !== undefined && (
- <span className="text-bambu-gray text-xs">{option.count}</span>
- )}
- </button>
- ))}
- </div>
- </>
- )}
- </div>
- );
- }
- // ============================================================================
- // SCROLL TO TOP BUTTON
- // ============================================================================
- function ScrollToTop() {
- const [isVisible, setIsVisible] = useState(false);
- useEffect(() => {
- const toggleVisibility = () => {
- setIsVisible(window.scrollY > 300);
- };
- window.addEventListener('scroll', toggleVisibility);
- return () => window.removeEventListener('scroll', toggleVisibility);
- }, []);
- const scrollToTop = () => {
- window.scrollTo({ top: 0, behavior: 'smooth' });
- };
- if (!isVisible) return null;
- return (
- <button
- onClick={scrollToTop}
- className="fixed bottom-6 right-6 p-3 bg-bambu-green hover:bg-bambu-green-light text-white rounded-full shadow-lg shadow-bambu-green/25 transition-all z-40"
- aria-label="Scroll to top"
- >
- <ArrowUp className="w-5 h-5" />
- </button>
- );
- }
- // ============================================================================
- // PRESET LIST ITEM (compact row style like K-Profiles)
- // ============================================================================
- function PresetListItem({
- setting,
- onClick,
- onDuplicate,
- compareMode,
- isCompareSelected,
- compareIndex,
- compareDisabled,
- t,
- }: {
- setting: SlicerSetting;
- onClick: () => void;
- onDuplicate: () => void;
- compareMode?: boolean;
- isCompareSelected?: boolean;
- compareIndex?: number;
- compareDisabled?: boolean;
- t: TFunction;
- }) {
- const metadata = extractMetadata(setting.name);
- const isEditable = isUserPreset(setting.setting_id);
- return (
- <div className="flex items-center gap-2 group">
- <button
- onClick={onClick}
- disabled={compareDisabled}
- className={`flex-1 text-left px-3 py-2 rounded transition-colors ${
- isCompareSelected
- ? 'bg-blue-500/20 border border-blue-500/50'
- : compareDisabled
- ? 'bg-bambu-dark/50 opacity-40 cursor-not-allowed'
- : 'bg-bambu-dark hover:bg-bambu-dark-tertiary'
- } ${compareMode && !compareDisabled ? 'cursor-pointer' : ''}`}
- >
- <div className="flex items-center gap-2">
- {isCompareSelected && compareIndex !== undefined && (
- <span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium">
- {compareIndex + 1}
- </span>
- )}
- {!isCompareSelected && isEditable && (
- <span className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-bambu-green" title={t('profiles.presets.myPreset')} />
- )}
- <span className="text-white text-sm truncate flex-1" title={setting.name}>
- {setting.name}
- </span>
- {/* Show relevant metadata tag */}
- {metadata.filamentType && setting.type === 'filament' && (
- <span className="text-xs text-bambu-gray whitespace-nowrap">
- {metadata.filamentType}
- </span>
- )}
- {metadata.layerHeight && setting.type === 'process' && (
- <span className="text-xs text-bambu-gray whitespace-nowrap">
- {metadata.layerHeight}
- </span>
- )}
- {metadata.printer && (
- <span className="text-xs text-bambu-gray whitespace-nowrap">
- {metadata.printer}
- </span>
- )}
- </div>
- </button>
- <button
- onClick={(e) => { e.stopPropagation(); onDuplicate(); }}
- className="opacity-0 group-hover:opacity-100 text-bambu-gray hover:text-white transition-all p-1"
- title={t('profiles.presets.duplicate')}
- >
- <Copy className="w-4 h-4" />
- </button>
- </div>
- );
- }
- // ============================================================================
- // PRESET DETAIL MODAL
- // ============================================================================
- // Format JSON for display, converting escaped newlines to real newlines in string values
- function formatJsonForDisplay(obj: unknown, indent = 0): string {
- const spaces = ' '.repeat(indent);
- if (obj === null) return 'null';
- if (obj === undefined) return 'undefined';
- if (typeof obj === 'string') {
- // Convert escaped newlines to actual newlines for readability
- if (obj.includes('\\n') || obj.includes('\n')) {
- const formatted = obj
- .replace(/\\n/g, '\n')
- .replace(/\\"/g, '"')
- .replace(/\\t/g, '\t');
- // For multi-line strings, show them nicely indented
- const lines = formatted.split('\n');
- if (lines.length > 1) {
- return '"""\n' + lines.map(l => spaces + ' ' + l).join('\n') + '\n' + spaces + '"""';
- }
- }
- return JSON.stringify(obj);
- }
- if (typeof obj === 'number' || typeof obj === 'boolean') {
- return String(obj);
- }
- if (Array.isArray(obj)) {
- if (obj.length === 0) return '[]';
- const items = obj.map(item => spaces + ' ' + formatJsonForDisplay(item, indent + 1));
- return '[\n' + items.join(',\n') + '\n' + spaces + ']';
- }
- if (typeof obj === 'object') {
- const entries = Object.entries(obj);
- if (entries.length === 0) return '{}';
- const items = entries.map(([key, val]) =>
- spaces + ' ' + JSON.stringify(key) + ': ' + formatJsonForDisplay(val, indent + 1)
- );
- return '{\n' + items.join(',\n') + '\n' + spaces + '}';
- }
- return String(obj);
- }
- function PresetDetailModal({
- setting,
- onClose,
- onDeleted,
- onDuplicate,
- onEdit,
- hasPermission,
- t,
- }: {
- setting: SlicerSetting;
- onClose: () => void;
- onDeleted: () => void;
- onDuplicate: () => void;
- onEdit: () => void;
- hasPermission: (permission: Permission) => boolean;
- t: TFunction;
- }) {
- const { showToast } = useToast();
- const queryClient = useQueryClient();
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
- const { data: detail, isLoading } = useQuery<SlicerSettingDetail>({
- queryKey: ['cloudSettingDetail', setting.setting_id],
- queryFn: () => api.getCloudSettingDetail(setting.setting_id),
- });
- const deleteMutation = useMutation({
- mutationFn: () => api.deleteCloudSetting(setting.setting_id),
- onSuccess: () => {
- showToast(t('profiles.presets.toast.deleted'));
- queryClient.invalidateQueries({ queryKey: ['cloudSettings'] });
- onDeleted();
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const isEditable = isUserPreset(setting.setting_id);
- const metadata = extractMetadata(setting.name, detail?.setting?.inherits as string);
- return (
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
- <Card className="w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
- <CardContent className="p-0 flex flex-col min-h-0 flex-1">
- {/* Header */}
- <div className="flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <div className="flex-1 min-w-0">
- <div className="flex items-center gap-2">
- <h2 className="text-xl font-semibold text-white truncate">{setting.name}</h2>
- {isEditable && (
- <span className="px-2 py-0.5 text-xs font-medium bg-bambu-green/20 text-bambu-green rounded-full">
- {t('profiles.presets.editable')}
- </span>
- )}
- </div>
- <div className="flex items-center gap-2 mt-1 text-sm text-bambu-gray">
- <span className="capitalize">{t(`profiles.presets.types.${setting.type}`)}</span>
- {metadata.printer && <><span>•</span><span>{metadata.printer}</span></>}
- </div>
- </div>
- <button onClick={onClose} className="p-2 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
- <X className="w-5 h-5" />
- </button>
- </div>
- {/* Content */}
- <div className="flex-1 min-h-0 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>
- ) : detail ? (
- <pre className="text-xs text-bambu-gray font-mono whitespace-pre-wrap break-all bg-bambu-dark p-4 rounded-lg border border-bambu-dark-tertiary overflow-x-auto max-w-full">
- {formatJsonForDisplay(detail)}
- </pre>
- ) : (
- <div className="text-center py-16 text-bambu-gray">{t('profiles.presets.failedToLoadDetails')}</div>
- )}
- </div>
- {/* Footer */}
- {showDeleteConfirm ? (
- <div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary bg-red-500/5">
- <div className="flex items-center gap-2 mb-3 text-red-400">
- <AlertTriangle className="w-5 h-5" />
- <span className="font-medium">{t('profiles.presets.deleteConfirm')}</span>
- </div>
- <p className="text-sm text-bambu-gray mb-4">
- {t('profiles.presets.deleteWarning', { name: setting.name })}
- </p>
- <div className="flex gap-2">
- <Button variant="secondary" onClick={() => setShowDeleteConfirm(false)} disabled={deleteMutation.isPending} className="flex-1">
- {t('common.cancel')}
- </Button>
- <Button variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} className="flex-1">
- {deleteMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
- {t('common.delete')}
- </Button>
- </div>
- </div>
- ) : (
- <div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary">
- <div className="flex gap-2">
- <Button variant="secondary" onClick={onClose} className="flex-1">{t('common.close')}</Button>
- <Button
- variant="secondary"
- onClick={onDuplicate}
- disabled={!hasPermission('cloud:auth')}
- title={!hasPermission('cloud:auth') ? t('profiles.presets.noDuplicatePermission') : undefined}
- >
- <Copy className="w-4 h-4" />
- {t('profiles.presets.duplicate')}
- </Button>
- {isEditable && (
- <>
- <Button
- variant="secondary"
- onClick={onEdit}
- disabled={isLoading || !detail || !hasPermission('cloud:auth')}
- title={!hasPermission('cloud:auth') ? t('profiles.presets.noEditPermission') : undefined}
- >
- <Pencil className="w-4 h-4" />
- {t('common.edit')}
- </Button>
- <Button
- variant="danger"
- onClick={() => setShowDeleteConfirm(true)}
- disabled={!hasPermission('cloud:auth')}
- title={!hasPermission('cloud:auth') ? t('profiles.presets.noDeletePermission') : undefined}
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- </>
- )}
- </div>
- </div>
- )}
- </CardContent>
- </Card>
- </div>
- );
- }
- // ============================================================================
- // TEMPLATES
- // ============================================================================
- type EditorTab = 'common' | 'fields' | 'json';
- interface CustomTemplate {
- id: string;
- name: string;
- description: string;
- type: 'filament' | 'print' | 'printer';
- settings: Record<string, unknown>;
- showInModal?: boolean; // If true, show in add/edit preset modals
- }
- // Load custom templates from localStorage
- function loadCustomTemplates(): CustomTemplate[] {
- try {
- const stored = localStorage.getItem('bambusy_preset_templates');
- return stored ? JSON.parse(stored) : [];
- } catch {
- return [];
- }
- }
- // Save custom templates to localStorage
- function saveCustomTemplates(templates: CustomTemplate[]) {
- localStorage.setItem('bambusy_preset_templates', JSON.stringify(templates));
- }
- // ============================================================================
- // TEMPLATES MODAL (manage templates from main page)
- // ============================================================================
- function TemplatesModal({
- onClose,
- onApply,
- t,
- }: {
- onClose: () => void;
- onApply: (template: CustomTemplate) => void;
- t: TFunction;
- }) {
- const { showToast } = useToast();
- const [templates, setTemplates] = useState<CustomTemplate[]>(loadCustomTemplates);
- const [filterType, setFilterType] = useState<'all' | 'filament' | 'print' | 'printer'>('all');
- const [editingId, setEditingId] = useState<string | null>(null);
- const [editName, setEditName] = useState('');
- const [editDesc, setEditDesc] = useState('');
- const [editSettings, setEditSettings] = useState('{}');
- const [editSettingsError, setEditSettingsError] = useState<string | null>(null);
- const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
- const filteredTemplates = filterType === 'all'
- ? templates
- : templates.filter(tpl => tpl.type === filterType);
- const saveTemplates = (updated: CustomTemplate[]) => {
- setTemplates(updated);
- saveCustomTemplates(updated);
- };
- const handleDelete = (id: string) => {
- const updated = templates.filter(tpl => tpl.id !== id);
- saveTemplates(updated);
- setDeleteConfirmId(null);
- showToast(t('profiles.templates.toast.deleted'));
- };
- const handleEdit = (template: CustomTemplate) => {
- setEditingId(template.id);
- setEditName(template.name);
- setEditDesc(template.description);
- setEditSettings(JSON.stringify(template.settings, null, 2));
- setEditSettingsError(null);
- };
- const handleSaveEdit = () => {
- if (!editingId || !editName.trim()) return;
- try {
- const settings = JSON.parse(editSettings);
- const updated = templates.map(tpl =>
- tpl.id === editingId
- ? { ...tpl, name: editName.trim(), description: editDesc.trim(), settings }
- : tpl
- );
- saveTemplates(updated);
- setEditingId(null);
- showToast(t('profiles.templates.toast.updated'));
- } catch (e) {
- setEditSettingsError((e as Error).message);
- }
- };
- const handleCancelEdit = () => {
- setEditingId(null);
- setEditName('');
- setEditDesc('');
- setEditSettings('{}');
- setEditSettingsError(null);
- };
- const toggleShowInModal = (id: string) => {
- const updated = templates.map(tpl =>
- tpl.id === id ? { ...tpl, showInModal: !tpl.showInModal } : tpl
- );
- saveTemplates(updated);
- };
- const typeLabels = {
- filament: { label: t('profiles.presets.types.filament'), icon: Droplet, color: 'text-amber-400' },
- print: { label: t('profiles.presets.types.process'), icon: Settings2, color: 'text-blue-400' },
- printer: { label: t('profiles.presets.types.printer'), icon: PrinterIcon, color: 'text-purple-400' },
- };
- const templateToDelete = deleteConfirmId ? templates.find(tpl => tpl.id === deleteConfirmId) : null;
- // Handle Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- if (deleteConfirmId) {
- setDeleteConfirmId(null);
- } else if (editingId) {
- handleCancelEdit();
- } else {
- onClose();
- }
- }
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [deleteConfirmId, editingId, onClose]);
- return (
- <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
- {/* Delete confirmation modal */}
- {templateToDelete && (
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60">
- <Card className="w-full max-w-md">
- <CardContent className="p-6">
- <div className="flex items-center gap-3 mb-4">
- <div className="p-2 bg-red-500/20 rounded-lg">
- <AlertTriangle className="w-6 h-6 text-red-400" />
- </div>
- <div>
- <h3 className="text-lg font-semibold text-white">{t('profiles.templates.deleteTitle')}</h3>
- <p className="text-sm text-bambu-gray">{t('profiles.templates.deleteWarning')}</p>
- </div>
- </div>
- <p className="text-white mb-6">
- {t('profiles.templates.deleteConfirm', { name: templateToDelete.name })}
- </p>
- <div className="flex gap-2">
- <Button variant="secondary" onClick={() => setDeleteConfirmId(null)} className="flex-1">
- {t('common.cancel')}
- </Button>
- <Button onClick={() => handleDelete(deleteConfirmId!)} className="flex-1 bg-red-500 hover:bg-red-600">
- <Trash2 className="w-4 h-4" />
- {t('common.delete')}
- </Button>
- </div>
- </CardContent>
- </Card>
- </div>
- )}
- <Card className="w-full max-w-2xl max-h-[80vh] flex flex-col">
- <CardContent className="p-0 flex flex-col h-full">
- {/* Header */}
- <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2">
- <Sparkles className="w-5 h-5 text-amber-400" />
- {t('profiles.templates.title')}
- </h2>
- <button onClick={onClose} className="text-bambu-gray hover:text-white">
- <X className="w-5 h-5" />
- </button>
- </div>
- {/* Filter row */}
- <div className="flex items-center gap-2 p-4 border-b border-bambu-dark-tertiary">
- <span className="text-sm text-bambu-gray">{t('profiles.templates.typeFilter')}</span>
- {(['all', 'filament', 'print', 'printer'] as const).map((type) => (
- <button
- key={type}
- onClick={() => setFilterType(type)}
- className={`px-3 py-1 text-sm rounded-lg transition-colors ${
- filterType === type
- ? 'bg-bambu-green text-white'
- : 'bg-bambu-dark text-bambu-gray hover:text-white'
- }`}
- >
- {type === 'all' ? t('common.all') : typeLabels[type].label}
- </button>
- ))}
- </div>
- {/* Templates list */}
- <div className="flex-1 overflow-y-auto p-4">
- {filteredTemplates.length === 0 ? (
- <div className="text-center py-12 text-bambu-gray">
- <Sparkles className="w-12 h-12 mx-auto mb-4 opacity-30" />
- <p>{t('profiles.templates.noTemplates')}</p>
- <p className="text-sm mt-1">{t('profiles.templates.createFirst')}</p>
- </div>
- ) : (
- <div className="space-y-2">
- {filteredTemplates.map((template) => {
- const typeInfo = typeLabels[template.type];
- const TypeIcon = typeInfo.icon;
- if (editingId === template.id) {
- return (
- <div
- key={template.id}
- className="p-4 bg-bambu-dark rounded-lg border border-bambu-green"
- >
- <div className="grid grid-cols-2 gap-3 mb-3">
- <input
- type="text"
- value={editName}
- onChange={(e) => setEditName(e.target.value)}
- placeholder={t('profiles.templates.namePlaceholder')}
- className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
- autoFocus
- />
- <input
- type="text"
- value={editDesc}
- onChange={(e) => setEditDesc(e.target.value)}
- placeholder={t('profiles.templates.descriptionPlaceholder')}
- className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
- />
- </div>
- <div className="mb-3">
- <label className="text-xs text-bambu-gray mb-1 block">{t('profiles.templates.settingsJson')}</label>
- <textarea
- value={editSettings}
- onChange={(e) => {
- setEditSettings(e.target.value);
- setEditSettingsError(null);
- }}
- rows={6}
- className={`w-full px-3 py-2 bg-bambu-dark-secondary border rounded text-white text-sm font-mono focus:outline-none ${
- editSettingsError ? 'border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'
- }`}
- />
- {editSettingsError && (
- <p className="text-xs text-red-400 mt-1">{editSettingsError}</p>
- )}
- </div>
- <div className="flex gap-2">
- <Button size="sm" onClick={handleSaveEdit} disabled={!editName.trim()}>
- <Save className="w-4 h-4" />
- Save
- </Button>
- <Button size="sm" variant="secondary" onClick={handleCancelEdit}>
- Cancel
- </Button>
- </div>
- </div>
- );
- }
- return (
- <div
- key={template.id}
- className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary hover:border-bambu-gray-dark transition-colors"
- >
- <TypeIcon className={`w-5 h-5 ${typeInfo.color} flex-shrink-0`} />
- <div className="flex-1 min-w-0">
- <p className="text-sm font-medium text-white">{template.name}</p>
- <p className="text-xs text-bambu-gray truncate">{template.description}</p>
- </div>
- <span className="text-xs text-bambu-gray-dark px-2 py-1 bg-bambu-dark-secondary rounded">
- {t('profiles.templates.fieldsCount', { count: Object.keys(template.settings).length })}
- </span>
- <button
- onClick={() => toggleShowInModal(template.id)}
- className={`p-1 transition-colors ${
- template.showInModal
- ? 'text-bambu-green hover:text-bambu-green/70'
- : 'text-bambu-gray hover:text-white'
- }`}
- title={template.showInModal ? t('profiles.templates.shownInModals') : t('profiles.templates.hiddenInModals')}
- >
- {template.showInModal ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
- </button>
- <button
- onClick={() => onApply(template)}
- className="px-3 py-1 text-xs bg-bambu-green/20 text-bambu-green rounded hover:bg-bambu-green/30 transition-colors"
- >
- {t('profiles.templates.apply')}
- </button>
- <button
- onClick={() => handleEdit(template)}
- className="p-1 text-bambu-gray hover:text-white"
- title={t('common.edit')}
- >
- <Pencil className="w-4 h-4" />
- </button>
- <button
- onClick={() => setDeleteConfirmId(template.id)}
- className="p-1 text-bambu-gray hover:text-red-400"
- title={t('common.delete')}
- >
- <Trash2 className="w-4 h-4" />
- </button>
- </div>
- );
- })}
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- );
- }
- // ============================================================================
- // DIFF MODAL - Compare two presets or preset vs base
- // ============================================================================
- type DiffEntry = {
- key: string;
- left: unknown;
- right: unknown;
- status: 'added' | 'removed' | 'changed' | 'same';
- };
- function DiffModal({
- onClose,
- leftPreset,
- rightPreset,
- leftLabel,
- rightLabel,
- t,
- }: {
- onClose: () => void;
- leftPreset: Record<string, unknown>;
- rightPreset: Record<string, unknown>;
- leftLabel: string;
- rightLabel: string;
- t: TFunction;
- }) {
- const [filterMode, setFilterMode] = useState<'changes' | 'all'>('changes');
- const [searchQuery, setSearchQuery] = useState('');
- // Calculate diff
- const diffEntries = useMemo(() => {
- const allKeys = new Set([...Object.keys(leftPreset), ...Object.keys(rightPreset)]);
- const entries: DiffEntry[] = [];
- for (const key of allKeys) {
- // Skip internal fields
- if (key === 'inherits' || key === 'version') continue;
- const leftVal = leftPreset[key];
- const rightVal = rightPreset[key];
- const leftExists = key in leftPreset;
- const rightExists = key in rightPreset;
- let status: DiffEntry['status'];
- if (!leftExists && rightExists) {
- status = 'added';
- } else if (leftExists && !rightExists) {
- status = 'removed';
- } else if (JSON.stringify(leftVal) !== JSON.stringify(rightVal)) {
- status = 'changed';
- } else {
- status = 'same';
- }
- entries.push({ key, left: leftVal, right: rightVal, status });
- }
- return entries.sort((a, b) => {
- // Sort by status (changed first, then added, removed, same)
- const statusOrder = { changed: 0, added: 1, removed: 2, same: 3 };
- if (statusOrder[a.status] !== statusOrder[b.status]) {
- return statusOrder[a.status] - statusOrder[b.status];
- }
- return a.key.localeCompare(b.key);
- });
- }, [leftPreset, rightPreset]);
- // Filter entries
- const filteredEntries = useMemo(() => {
- let entries = [...diffEntries];
- if (filterMode === 'changes') {
- entries = entries.filter(e => e.status !== 'same');
- }
- if (searchQuery) {
- const q = searchQuery.toLowerCase();
- entries = entries.filter(e =>
- e.key.toLowerCase().includes(q) ||
- String(e.left).toLowerCase().includes(q) ||
- String(e.right).toLowerCase().includes(q)
- );
- }
- return entries;
- }, [diffEntries, filterMode, searchQuery]);
- // Stats
- const stats = useMemo(() => {
- return {
- added: diffEntries.filter(e => e.status === 'added').length,
- removed: diffEntries.filter(e => e.status === 'removed').length,
- changed: diffEntries.filter(e => e.status === 'changed').length,
- same: diffEntries.filter(e => e.status === 'same').length,
- };
- }, [diffEntries]);
- const formatValue = (val: unknown): string => {
- if (val === undefined) return '—';
- if (val === null) return 'null';
- if (Array.isArray(val)) {
- // Show arrays more cleanly
- if (val.length === 0) return '[]';
- if (val.length === 1) return String(val[0]);
- return val.join(', ');
- }
- if (typeof val === 'object') return JSON.stringify(val);
- // Handle strings - truncate long ones and clean up escaped chars
- const str = String(val);
- // Check if it looks like G-code or multi-line content
- if (str.includes('\\n') || str.length > 100) {
- // Count lines and show summary
- const lines = str.split('\\n').length;
- if (lines > 1) {
- return `[${lines} lines of G-code/script]`;
- }
- if (str.length > 100) {
- return str.substring(0, 100) + '…';
- }
- }
- return str;
- };
- // Handle Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [onClose]);
- return (
- <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
- <Card className="w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
- <CardContent className="p-0 flex flex-col min-h-0 flex-1">
- {/* Header */}
- <div className="flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2">
- <GitCompare className="w-5 h-5 text-blue-400" />
- {t('profiles.diff.title')}
- </h2>
- <button onClick={onClose} className="text-bambu-gray hover:text-white">
- <X className="w-5 h-5" />
- </button>
- </div>
- {/* Preset labels */}
- <div className="flex-shrink-0 grid grid-cols-2 gap-4 p-4 border-b border-bambu-dark-tertiary bg-bambu-dark">
- <div className="text-center">
- <span className="text-sm text-bambu-gray">{t('profiles.diff.left')}</span>
- <p className="text-white font-medium truncate">{leftLabel}</p>
- </div>
- <div className="text-center">
- <span className="text-sm text-bambu-gray">{t('profiles.diff.right')}</span>
- <p className="text-white font-medium truncate">{rightLabel}</p>
- </div>
- </div>
- {/* Stats and filters */}
- <div className="flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <div className="flex items-center gap-4 text-sm">
- <span className="flex items-center gap-1 text-green-400">
- <PlusIcon className="w-3.5 h-3.5" />
- {stats.added} {t('profiles.diff.added')}
- </span>
- <span className="flex items-center gap-1 text-red-400">
- <MinusIcon className="w-3.5 h-3.5" />
- {stats.removed} {t('profiles.diff.removed')}
- </span>
- <span className="flex items-center gap-1 text-amber-400">
- <ArrowRight className="w-3.5 h-3.5" />
- {stats.changed} {t('profiles.diff.changed')}
- </span>
- <span className="flex items-center gap-1 text-bambu-gray">
- <Equal className="w-3.5 h-3.5" />
- {stats.same} {t('profiles.diff.same')}
- </span>
- </div>
- <div className="flex items-center gap-3">
- <div className="relative">
- <Search className="absolute left-2 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.diff.searchFields')}
- className="pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none w-48"
- />
- </div>
- {stats.same > 0 && (
- <div className="flex rounded overflow-hidden border border-bambu-dark-tertiary">
- <button
- onClick={() => setFilterMode('changes')}
- className={`px-3 py-1.5 text-sm transition-colors ${
- filterMode === 'changes'
- ? 'bg-bambu-green text-white'
- : 'bg-bambu-dark text-bambu-gray hover:text-white'
- }`}
- >
- {t('profiles.diff.changes')}
- </button>
- <button
- onClick={() => setFilterMode('all')}
- className={`px-3 py-1.5 text-sm transition-colors ${
- filterMode === 'all'
- ? 'bg-bambu-green text-white'
- : 'bg-bambu-dark text-bambu-gray hover:text-white'
- }`}
- >
- {t('common.all')}
- </button>
- </div>
- )}
- </div>
- </div>
- {/* Diff table */}
- <div className="flex-1 min-h-0 overflow-y-auto">
- {filteredEntries.length === 0 ? (
- <div className="text-center py-12 text-bambu-gray">
- <Equal className="w-12 h-12 mx-auto mb-4 opacity-30" />
- <p>{filterMode === 'changes' ? t('profiles.diff.noDifferences') : t('profiles.diff.noFieldsMatch')}</p>
- </div>
- ) : (
- <table className="w-full">
- <thead className="sticky top-0 bg-bambu-dark-secondary">
- <tr className="text-sm text-bambu-gray border-b border-bambu-dark-tertiary">
- <th className="text-left p-3 w-1/3">{t('profiles.diff.field')}</th>
- <th className="text-left p-3 w-1/3">{leftLabel}</th>
- <th className="text-left p-3 w-1/3">{rightLabel}</th>
- </tr>
- </thead>
- <tbody>
- {filteredEntries.map((entry) => {
- const bgClass = {
- added: 'bg-green-500/10',
- removed: 'bg-red-500/10',
- changed: 'bg-amber-500/10',
- same: '',
- }[entry.status];
- const statusIcon = {
- added: <PlusIcon className="w-3.5 h-3.5 text-green-400" />,
- removed: <MinusIcon className="w-3.5 h-3.5 text-red-400" />,
- changed: <ArrowRight className="w-3.5 h-3.5 text-amber-400" />,
- same: <Equal className="w-3.5 h-3.5 text-bambu-gray-dark" />,
- }[entry.status];
- return (
- <tr key={entry.key} className={`border-b border-bambu-dark-tertiary ${bgClass}`}>
- <td className="p-3">
- <div className="flex items-center gap-2">
- {statusIcon}
- <span className="text-sm text-white font-mono">{entry.key}</span>
- </div>
- </td>
- <td className="p-3">
- <span className={`text-sm font-mono break-all ${
- entry.status === 'removed' ? 'text-red-300' :
- entry.status === 'changed' ? 'text-white' : 'text-bambu-gray'
- }`}>
- {formatValue(entry.left)}
- </span>
- </td>
- <td className="p-3">
- <span className={`text-sm font-mono break-all ${
- entry.status === 'added' ? 'text-green-300' :
- entry.status === 'changed' ? 'text-white' : 'text-bambu-gray'
- }`}>
- {formatValue(entry.right)}
- </span>
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- );
- }
- // ============================================================================
- // CREATE PRESET MODAL
- // ============================================================================
- function CreatePresetModal({
- onClose,
- initialData,
- allPresets,
- t,
- }: {
- onClose: () => void;
- initialData?: { type: string; name: string; base_id: string; setting: Record<string, unknown>; setting_id?: string };
- allPresets: SlicerSettingsResponse;
- t: TFunction;
- }) {
- const { showToast } = useToast();
- const queryClient = useQueryClient();
- // Editing mode if initialData has setting_id
- const isEditMode = !!initialData?.setting_id;
- const [activeTab, setActiveTab] = useState<EditorTab>('common');
- const [presetType, setPresetType] = useState<'filament' | 'print' | 'printer'>(
- (initialData?.type as 'filament' | 'print' | 'printer') || 'filament'
- );
- const [name, setName] = useState(
- initialData?.name
- ? (isEditMode ? initialData.name : `${initialData.name} (Copy)`)
- : ''
- );
- const [baseId, setBaseId] = useState(initialData?.base_id || '');
- const [baseName, setBaseName] = useState('');
- const [settingsObj, setSettingsObj] = useState<Record<string, unknown>>(
- initialData?.setting || { inherits: '' }
- );
- const [jsonText, setJsonText] = useState(JSON.stringify(initialData?.setting || { inherits: '' }, null, 2));
- const [jsonError, setJsonError] = useState<string | null>(null);
- const [fieldSearch, setFieldSearch] = useState('');
- const [isDragging, setIsDragging] = useState(false);
- const [customFieldKey, setCustomFieldKey] = useState('');
- const [showCustomFieldInput, setShowCustomFieldInput] = useState(false);
- const [customTemplates, setCustomTemplates] = useState<CustomTemplate[]>(loadCustomTemplates);
- const [showSaveTemplate, setShowSaveTemplate] = useState(false);
- const [newTemplateName, setNewTemplateName] = useState('');
- const [newTemplateDesc, setNewTemplateDesc] = useState('');
- const [newTemplateShowInModal, setNewTemplateShowInModal] = useState(true);
- const [appliedTemplateName, setAppliedTemplateName] = useState<string | null>(null);
- const [showDiffModal, setShowDiffModal] = useState(false);
- // Fetch ALL preset details for the current type to discover all available fields
- const presetsOfType = useMemo(() => {
- const typeMap: Record<string, SlicerSetting[]> = {
- filament: allPresets.filament,
- print: allPresets.process,
- printer: allPresets.printer,
- };
- return typeMap[presetType] || [];
- }, [allPresets, presetType]);
- // Only fetch details for USER presets (not Bambu's built-in ones which return 500)
- const userPresetsOfType = useMemo(() => {
- return presetsOfType.filter(p => isUserPreset(p.setting_id));
- }, [presetsOfType]);
- // Fetch field definitions from API (cached, only loaded once per type)
- const { data: fieldDefinitions } = useQuery({
- queryKey: ['cloudFields', presetType],
- queryFn: () => api.getCloudFields(presetType === 'print' ? 'process' : presetType),
- staleTime: 1000 * 60 * 60, // Cache for 1 hour
- });
- // Fetch details for user presets of this type (for field discovery)
- const { data: allPresetDetails } = useQuery({
- queryKey: ['allPresetDetails', presetType, userPresetsOfType.map(p => p.setting_id).join(',')],
- queryFn: async () => {
- // Fetch all preset details in parallel (limit concurrency to avoid overwhelming API)
- const results: Record<string, SlicerSettingDetail> = {};
- const batchSize = 5;
- for (let i = 0; i < userPresetsOfType.length; i += batchSize) {
- const batch = userPresetsOfType.slice(i, i + batchSize);
- const batchResults = await Promise.all(
- batch.map(async (preset) => {
- try {
- const detail = await api.getCloudSettingDetail(preset.setting_id);
- return { id: preset.setting_id, detail };
- } catch {
- return null;
- }
- })
- );
- batchResults.forEach(r => {
- if (r) results[r.id] = r.detail;
- });
- }
- return results;
- },
- enabled: userPresetsOfType.length > 0,
- staleTime: 1000 * 60 * 10, // Cache for 10 minutes
- });
- // Fetch base preset details (works for both user presets and built-in presets with new API version)
- const { data: basePresetDetail, isLoading: isLoadingBasePreset } = useQuery<SlicerSettingDetail>({
- queryKey: ['cloudSettingDetail', baseId],
- queryFn: () => api.getCloudSettingDetail(baseId),
- enabled: !!baseId,
- });
- // Sync JSON text with settings object
- useEffect(() => {
- if (activeTab !== 'json') {
- setJsonText(JSON.stringify(settingsObj, null, 2));
- }
- }, [settingsObj, activeTab]);
- // Get presets filtered by selected type - only built-in presets allowed as base
- // (Bambu Cloud only allows custom presets to inherit from built-in presets)
- const availableBasePresets = useMemo(() => {
- const typeMap: Record<string, SlicerSetting[]> = {
- filament: allPresets.filament,
- print: allPresets.process,
- printer: allPresets.printer,
- };
- return (typeMap[presetType] || [])
- .filter(p => !isUserPreset(p.setting_id)) // Only built-in presets
- .sort((a, b) => a.name.localeCompare(b.name));
- }, [allPresets, presetType]);
- // Set inherits field when base preset changes (don't pre-fill all values - they show as placeholders)
- // In edit mode, don't reset settingsObj - keep the saved values
- useEffect(() => {
- if (!baseId) return;
- const preset = availableBasePresets.find(p => p.setting_id === baseId);
- if (preset) {
- setBaseName(preset.name);
- // Don't reset settings in edit mode - keep saved values
- if (!isEditMode) {
- setSettingsObj({ inherits: preset.name });
- setJsonText(JSON.stringify({ inherits: preset.name }, null, 2));
- }
- }
- }, [baseId, availableBasePresets, isEditMode]);
- // Build dynamic fields list: merge API definitions with discovered fields from user presets
- const dynamicFields = useMemo(() => {
- // Use API field definitions if available
- const knownFields: FieldDefinition[] = fieldDefinitions?.fields || [];
- const knownKeySet = new Set(knownFields.map(f => f.key));
- // Collect all unique field keys from ALL user presets of this type
- const discoveredKeys = new Set<string>();
- const excludeKeys = new Set(['inherits', 'updated_time', 'compatible_printers', 'compatible_prints']);
- // From all preset details
- if (allPresetDetails) {
- Object.values(allPresetDetails).forEach(detail => {
- if (detail?.setting) {
- Object.keys(detail.setting).forEach(key => {
- if (!knownKeySet.has(key) && !excludeKeys.has(key)) {
- discoveredKeys.add(key);
- }
- });
- }
- });
- }
- // From current settings (in case user added custom fields)
- Object.keys(settingsObj).forEach(key => {
- if (!knownKeySet.has(key) && !excludeKeys.has(key)) {
- discoveredKeys.add(key);
- }
- });
- // Create field definitions for discovered keys (generic text inputs)
- const discoveredFields: FieldDefinition[] = Array.from(discoveredKeys)
- .sort()
- .map(key => ({
- key,
- label: key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
- type: 'text' as const,
- category: 'discovered',
- description: t('profiles.presets.discoveredFromPresets'),
- }));
- return [...knownFields, ...discoveredFields];
- }, [fieldDefinitions, allPresetDetails, settingsObj, t]);
- // Filter fields for search
- const filteredFields = dynamicFields.filter(f =>
- f.label.toLowerCase().includes(fieldSearch.toLowerCase()) ||
- f.key.toLowerCase().includes(fieldSearch.toLowerCase())
- );
- // Add a custom field
- const addCustomField = () => {
- if (customFieldKey.trim()) {
- const key = customFieldKey.trim().toLowerCase().replace(/\s+/g, '_');
- updateField(key, '');
- setCustomFieldKey('');
- setShowCustomFieldInput(false);
- showToast(t('profiles.presets.toast.fieldAdded', { key }));
- }
- };
- // Update a single field
- const updateField = (key: string, value: unknown) => {
- setSettingsObj(prev => {
- const newObj = { ...prev };
- if (value === '' || value === undefined) {
- delete newObj[key];
- } else {
- newObj[key] = value;
- }
- return newObj;
- });
- };
- // Apply a template
- const applyTemplate = (template: { name: string; settings: Record<string, unknown> }) => {
- setSettingsObj(prev => ({ ...prev, ...template.settings }));
- setAppliedTemplateName(template.name);
- showToast(t('profiles.templates.toast.applied'));
- };
- // Save current settings as a template
- const saveAsTemplate = () => {
- if (!newTemplateName.trim()) return;
- const overrides = { ...settingsObj };
- delete overrides.inherits;
- if (Object.keys(overrides).length === 0) {
- showToast(t('profiles.presets.noOverridesToSave'), 'error');
- return;
- }
- const newTemplate: CustomTemplate = {
- id: Date.now().toString(),
- name: newTemplateName.trim(),
- description: newTemplateDesc.trim() || t('profiles.presets.customTemplate'),
- type: presetType,
- settings: overrides,
- showInModal: newTemplateShowInModal,
- };
- const updated = [...customTemplates, newTemplate];
- setCustomTemplates(updated);
- saveCustomTemplates(updated);
- setShowSaveTemplate(false);
- setNewTemplateName('');
- setNewTemplateDesc('');
- setNewTemplateShowInModal(true);
- showToast(t('profiles.templates.toast.created'));
- };
- // Get templates for current type (only those marked to show in modals)
- const templatesForType = useMemo(() => {
- return customTemplates.filter(t => t.type === presetType && t.showInModal);
- }, [presetType, customTemplates]);
- // Handle JSON edit
- const handleJsonChange = (text: string) => {
- setJsonText(text);
- try {
- const parsed = JSON.parse(text);
- setSettingsObj(parsed);
- setJsonError(null);
- } catch (e) {
- setJsonError((e as Error).message);
- }
- };
- // Handle file drop
- const handleFileDrop = (e: React.DragEvent) => {
- e.preventDefault();
- setIsDragging(false);
- const file = e.dataTransfer.files[0];
- if (file && file.name.endsWith('.json')) {
- const reader = new FileReader();
- reader.onload = (event) => {
- try {
- const content = event.target?.result as string;
- const parsed = JSON.parse(content);
- // Handle both full preset format and settings-only format
- const settings = parsed.setting || parsed;
- setSettingsObj(prev => ({ ...prev, ...settings }));
- setJsonText(JSON.stringify({ ...settingsObj, ...settings }, null, 2));
- showToast(t('profiles.presets.fileImported'));
- } catch {
- showToast(t('profiles.presets.invalidJsonFile'), 'error');
- }
- };
- reader.readAsText(file);
- }
- };
- const createMutation = useMutation({
- mutationFn: () => {
- const finalSettings = { ...settingsObj };
- const settingsIdKey = presetType === 'filament' ? 'filament_settings_id'
- : presetType === 'print' ? 'print_settings_id' : 'printer_settings_id';
- finalSettings[settingsIdKey] = `"${name}"`;
- const data: SlicerSettingCreate = { type: presetType, name, base_id: baseId, setting: finalSettings };
- return api.createCloudSetting(data);
- },
- onSuccess: async () => {
- showToast(t('profiles.presets.toast.created'));
- // Force immediate refetch of the settings list
- await queryClient.refetchQueries({ queryKey: ['cloudSettings'] });
- onClose();
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const updateMutation = useMutation({
- mutationFn: () => {
- if (!initialData?.setting_id) throw new Error(t('profiles.presets.noSettingId'));
- return api.updateCloudSetting(initialData.setting_id, { name, setting: settingsObj });
- },
- onSuccess: async () => {
- showToast(t('profiles.presets.toast.updated'));
- // Clear all detail caches to ensure fresh data
- queryClient.removeQueries({ queryKey: ['cloudSettingDetail'] });
- // Force immediate refetch of the settings list
- await queryClient.refetchQueries({ queryKey: ['cloudSettings'] });
- onClose();
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const saveMutation = isEditMode ? updateMutation : createMutation;
- // Check if base preset inherits from another preset (for user presets that only store overrides)
- const inheritedPresetName = basePresetDetail?.setting?.inherits as string | undefined;
- const inheritedPreset = inheritedPresetName
- ? availableBasePresets.find(p => p.name === inheritedPresetName)
- : undefined;
- // Fetch the inherited preset's full values (if applicable)
- const { data: inheritedPresetDetail } = useQuery<SlicerSettingDetail>({
- queryKey: ['cloudSettingDetail', inheritedPreset?.setting_id],
- queryFn: () => api.getCloudSettingDetail(inheritedPreset!.setting_id),
- enabled: !!inheritedPreset?.setting_id,
- });
- // Get base preset values - merge inherited values with overrides
- const basePresetValues = useMemo(() => {
- // Start with inherited preset's values (full base)
- const inheritedValues = inheritedPresetDetail?.setting as Record<string, unknown> || {};
- // Get the selected preset's values (could be overrides only)
- const selectedValues = basePresetDetail?.setting as Record<string, unknown> || {};
- // Fallback to allPresetDetails if no dedicated query result
- const fallbackValues = baseId && allPresetDetails?.[baseId]?.setting
- ? allPresetDetails[baseId].setting as Record<string, unknown>
- : {};
- // Merge: inherited base values + selected preset overrides
- // Selected values take precedence
- return {
- ...inheritedValues,
- ...selectedValues,
- ...fallbackValues,
- };
- }, [baseId, basePresetDetail, inheritedPresetDetail, allPresetDetails]);
- // Format a value for display (handles arrays, objects, etc.)
- const formatValue = (val: unknown): string => {
- if (val === undefined || val === null) return '';
- if (Array.isArray(val)) {
- // For arrays, join with comma or take first value if all same
- const unique = [...new Set(val.map(v => String(v)))];
- return unique.length === 1 ? unique[0] : val.join(', ');
- }
- return String(val);
- };
- // Render a field input
- const renderFieldInput = (field: FieldDefinition) => {
- const value = settingsObj[field.key] as string | number | boolean | undefined;
- const baseValue = basePresetValues[field.key];
- const formattedBaseValue = formatValue(baseValue);
- // Always show base value as placeholder when available
- const placeholder = isLoadingBasePreset
- ? t('common.loading')
- : (formattedBaseValue || '');
- const baseClass = "w-full px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none";
- if (field.type === 'boolean') {
- const isOn = value === '1' || (value === undefined && baseValue === '1');
- return (
- <button
- type="button"
- onClick={() => updateField(field.key, value === '1' ? '0' : '1')}
- className={`w-8 h-5 rounded-full transition-colors ${isOn ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'}`}
- >
- <div className={`w-4 h-4 rounded-full bg-white shadow transition-transform ${isOn ? 'translate-x-3.5' : 'translate-x-0.5'}`} />
- </button>
- );
- }
- if (field.type === 'select') {
- return (
- <select
- value={(value as string) || ''}
- onChange={(e) => updateField(field.key, e.target.value)}
- className={baseClass}
- >
- <option value="">{placeholder}</option>
- {field.options?.map(opt => (
- <option key={opt.value} value={opt.value}>{opt.label}</option>
- ))}
- </select>
- );
- }
- return (
- <div className="flex items-center gap-2">
- <input
- type={field.type === 'number' ? 'number' : 'text'}
- value={value !== undefined ? String(value) : ''}
- onChange={(e) => updateField(field.key, e.target.value)}
- step={field.step}
- min={field.min}
- max={field.max}
- placeholder={placeholder}
- className={baseClass}
- />
- {field.unit && <span className="text-xs text-bambu-gray whitespace-nowrap">{field.unit}</span>}
- </div>
- );
- };
- // Get base preset settings for diff comparison
- const basePresetSettings = useMemo(() => {
- if (!basePresetDetail?.setting) return {};
- return basePresetDetail.setting as Record<string, unknown>;
- }, [basePresetDetail]);
- return (
- <div
- className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
- onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
- onDragLeave={() => setIsDragging(false)}
- onDrop={handleFileDrop}
- >
- {/* Diff Modal */}
- {showDiffModal && baseId && (
- <DiffModal
- onClose={() => setShowDiffModal(false)}
- leftPreset={basePresetSettings}
- rightPreset={settingsObj}
- leftLabel={t('profiles.presets.baseLabel', { name: baseName || baseId })}
- rightLabel={t('profiles.presets.currentLabel', { name: name || t('profiles.presets.newPreset') })}
- t={t}
- />
- )}
- <Card className="w-full max-w-6xl max-h-[90vh] flex flex-col">
- <CardContent className="p-0 flex flex-col h-full">
- {/* Header */}
- <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <div>
- <h2 className="text-xl font-semibold text-white">
- {isEditMode ? t('profiles.presets.editPreset') : (initialData ? t('profiles.presets.duplicatePreset') : t('profiles.presets.createNewPreset'))}
- </h2>
- <p className="text-sm text-bambu-gray mt-1">
- {t('profiles.presets.customizeSettings')}
- </p>
- </div>
- <div className="flex items-center gap-2">
- {baseId && (
- <button
- onClick={() => setShowDiffModal(true)}
- className="flex items-center gap-2 px-3 py-2 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
- title={t('profiles.presets.compareWithBase')}
- >
- <GitCompare className="w-4 h-4" />
- {t('profiles.presets.compare')}
- </button>
- )}
- <button onClick={onClose} className="p-2 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
- <X className="w-5 h-5" />
- </button>
- </div>
- </div>
- {/* Drag overlay */}
- {isDragging && (
- <div className="absolute inset-0 bg-bambu-green/10 border-2 border-dashed border-bambu-green rounded-lg flex items-center justify-center z-10">
- <div className="text-center">
- <Upload className="w-12 h-12 text-bambu-green mx-auto mb-2" />
- <p className="text-bambu-green font-medium">{t('profiles.presets.dropJsonToImport')}</p>
- </div>
- </div>
- )}
- {/* Basic Info */}
- <div className="p-4 border-b border-bambu-dark-tertiary space-y-3">
- <div className="grid grid-cols-3 gap-4">
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('common.type')}</label>
- <select
- value={presetType}
- onChange={(e) => { setPresetType(e.target.value as 'filament' | 'print' | 'printer'); setBaseId(''); }}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- >
- <option value="filament">{t('profiles.presets.types.filament')}</option>
- <option value="print">{t('profiles.presets.types.process')}</option>
- <option value="printer">{t('profiles.presets.types.printer')}</option>
- </select>
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('profiles.presets.basePreset')}</label>
- <select
- value={baseId}
- onChange={(e) => setBaseId(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
- >
- <option value="">{t('profiles.presets.selectBasePreset')}</option>
- {availableBasePresets.map((preset) => (
- <option key={preset.setting_id} value={preset.setting_id}>{preset.name}</option>
- ))}
- </select>
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('profiles.presets.presetName')}</label>
- <input
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- placeholder={t('profiles.presets.myCustomPreset')}
- />
- </div>
- </div>
- {baseName && (
- <div className="text-xs text-bambu-gray">
- <p className="flex items-center gap-1">
- <Check className="w-3 h-3 text-bambu-green" />
- {t('profiles.presets.inheritsFrom')} <span className="text-white">{baseName}</span>
- {isLoadingBasePreset && (
- <Loader2 className="w-3 h-3 animate-spin ml-1" />
- )}
- </p>
- </div>
- )}
- </div>
- {/* Tabs */}
- <div className="flex border-b border-bambu-dark-tertiary">
- <button
- onClick={() => setActiveTab('common')}
- className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
- activeTab === 'common' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'
- }`}
- >
- <Sliders className="w-4 h-4" />
- {t('profiles.presets.tabs.common')}
- </button>
- <button
- onClick={() => setActiveTab('fields')}
- className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
- activeTab === 'fields' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'
- }`}
- >
- <List className="w-4 h-4" />
- {t('profiles.presets.tabs.allFields')}
- </button>
- <button
- onClick={() => setActiveTab('json')}
- className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
- activeTab === 'json' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'
- }`}
- >
- <Code className="w-4 h-4" />
- JSON
- {jsonError && <AlertCircle className="w-3 h-3 text-red-400" />}
- </button>
- <div className="flex-1" />
- <button
- onClick={() => {
- const exportData = {
- name,
- type: presetType,
- base_id: baseId,
- setting: settingsObj,
- };
- const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `${name || 'preset'}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- showToast(t('profiles.presets.toast.exported'));
- }}
- className="flex items-center gap-2 px-4 py-3 text-sm text-bambu-gray hover:text-white transition-colors"
- title={t('profiles.presets.exportToJson')}
- >
- <Download className="w-4 h-4" />
- {t('common.download')}
- </button>
- <button
- onClick={() => document.getElementById('file-import')?.click()}
- className="flex items-center gap-2 px-4 py-3 text-sm text-bambu-gray hover:text-white transition-colors"
- title={t('profiles.presets.importFromJson')}
- >
- <Upload className="w-4 h-4" />
- {t('common.upload')}
- </button>
- <input
- id="file-import"
- type="file"
- accept=".json"
- className="hidden"
- onChange={(e) => {
- const file = e.target.files?.[0];
- if (file) {
- const reader = new FileReader();
- reader.onload = (event) => {
- try {
- const parsed = JSON.parse(event.target?.result as string);
- const settings = parsed.setting || parsed;
- setSettingsObj(prev => ({ ...prev, ...settings }));
- showToast(t('profiles.presets.fileImported'));
- } catch {
- showToast(t('profiles.presets.invalidJson'), 'error');
- }
- };
- reader.readAsText(file);
- }
- }}
- />
- </div>
- {/* Tab Content */}
- <div className="flex-1 overflow-y-auto p-4">
- {activeTab === 'common' && (
- <div className="space-y-6">
- {/* Templates */}
- <div>
- <div className="flex items-center justify-between mb-3">
- <h3 className="text-sm font-medium text-white flex items-center gap-2">
- <Sparkles className="w-4 h-4 text-amber-400" />
- {t('profiles.templates.title')}
- </h3>
- {Object.keys(settingsObj).filter(k => k !== 'inherits').length > 0 && (
- <button
- onClick={() => setShowSaveTemplate(!showSaveTemplate)}
- className="text-xs text-bambu-gray hover:text-white flex items-center gap-1 transition-colors"
- >
- <Save className="w-3 h-3" />
- {t('profiles.presets.saveAsTemplate')}
- </button>
- )}
- </div>
- {showSaveTemplate && (
- <div className="mb-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
- <div className="grid grid-cols-2 gap-2 mb-2">
- <input
- type="text"
- value={newTemplateName}
- onChange={(e) => setNewTemplateName(e.target.value)}
- placeholder={t('profiles.templates.namePlaceholder')}
- className="px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
- autoFocus
- />
- <input
- type="text"
- value={newTemplateDesc}
- onChange={(e) => setNewTemplateDesc(e.target.value)}
- placeholder={t('profiles.templates.descriptionPlaceholder')}
- className="px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
- />
- </div>
- <div className="flex items-center justify-between">
- <div className="flex gap-2">
- <Button size="sm" onClick={saveAsTemplate} disabled={!newTemplateName.trim()}>
- <Save className="w-3 h-3" />
- {t('common.save')}
- </Button>
- <Button size="sm" variant="secondary" onClick={() => setShowSaveTemplate(false)}>
- {t('common.cancel')}
- </Button>
- </div>
- <button
- onClick={() => setNewTemplateShowInModal(!newTemplateShowInModal)}
- className={`flex items-center gap-1.5 text-xs transition-colors ${
- newTemplateShowInModal ? 'text-bambu-green' : 'text-bambu-gray hover:text-white'
- }`}
- >
- {newTemplateShowInModal ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
- {newTemplateShowInModal ? t('profiles.templates.shownInModals') : t('profiles.templates.hiddenInModals')}
- </button>
- </div>
- </div>
- )}
- {/* Applied template indicator */}
- {appliedTemplateName && (
- <div className="mb-3 px-3 py-2 bg-bambu-green/10 border border-bambu-green/30 rounded-lg flex items-center gap-2">
- <Check className="w-4 h-4 text-bambu-green" />
- <span className="text-sm text-bambu-green">{t('profiles.presets.templateApplied')} <span className="font-medium">{appliedTemplateName}</span></span>
- <button
- onClick={() => setAppliedTemplateName(null)}
- className="ml-auto text-bambu-green/70 hover:text-bambu-green"
- >
- <X className="w-4 h-4" />
- </button>
- </div>
- )}
- <div className="grid grid-cols-3 gap-2">
- {templatesForType.map((template) => (
- <button
- key={template.id}
- onClick={() => applyTemplate(template)}
- className="p-3 text-left bg-bambu-dark border border-bambu-dark-tertiary rounded-lg hover:border-bambu-gray-dark transition-colors"
- >
- <p className="text-sm font-medium text-white">{template.name}</p>
- <p className="text-xs text-bambu-gray mt-1">{template.description}</p>
- </button>
- ))}
- {templatesForType.length === 0 && (
- <p className="col-span-3 text-center text-bambu-gray text-sm py-4">
- {t('profiles.presets.noTemplatesSelected')}
- </p>
- )}
- </div>
- {/* Note about template management */}
- <p className="text-xs text-bambu-gray-dark mt-2 text-center">
- {t('profiles.presets.manageTemplatesHint')}
- </p>
- </div>
- {/* Common Fields */}
- <div>
- <h3 className="text-sm font-medium text-white mb-3">{t('profiles.presets.commonSettings')}</h3>
- <div className="grid grid-cols-2 gap-x-6 gap-y-3">
- {dynamicFields.slice(0, 10).map(field => (
- <div key={field.key} className="flex items-center justify-between gap-4">
- <label className="text-sm text-bambu-gray flex-shrink-0">{field.label}</label>
- <div className="w-48">{renderFieldInput(field)}</div>
- </div>
- ))}
- </div>
- </div>
- {/* Current overrides */}
- {Object.keys(settingsObj).length > 1 && (
- <div>
- <h3 className="text-sm font-medium text-white mb-3">{t('profiles.presets.currentOverrides')}</h3>
- <div className="flex flex-wrap gap-2">
- {Object.entries(settingsObj)
- .filter(([k]) => k !== 'inherits')
- .map(([key, value]) => (
- <span key={key} className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-green/10 text-bambu-green text-xs rounded">
- {key}: {String(value).slice(0, 20)}
- <button onClick={() => updateField(key, undefined)} className="hover:text-white">
- <X className="w-3 h-3" />
- </button>
- </span>
- ))}
- </div>
- </div>
- )}
- </div>
- )}
- {activeTab === 'fields' && (
- <div className="grid grid-cols-2 gap-6" style={{ height: '400px' }}>
- {/* Left: Available Fields */}
- <div className="flex flex-col h-full overflow-hidden">
- <div className="flex items-center justify-between mb-3 flex-shrink-0">
- <h3 className="text-sm font-medium text-white">{t('profiles.presets.availableFields')}</h3>
- <span className="text-xs text-bambu-gray">
- {allPresetDetails
- ? t('profiles.templates.fieldsCount', { count: dynamicFields.length })
- : t('common.loading')}
- </span>
- </div>
- <div className="relative mb-3 flex-shrink-0">
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
- <input
- type="text"
- value={fieldSearch}
- onChange={(e) => setFieldSearch(e.target.value)}
- placeholder={t('profiles.presets.searchFieldsPlaceholder')}
- className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
- />
- </div>
- <div className="flex-1 overflow-y-auto space-y-1 pr-2 min-h-0">
- {filteredFields
- .filter(f => !(f.key in settingsObj))
- .map(field => {
- const baseVal = basePresetValues[field.key];
- const formattedVal = formatValue(baseVal);
- return (
- <div
- key={field.key}
- onClick={() => {
- // Add field directly (don't use updateField which deletes on empty)
- setSettingsObj(prev => ({ ...prev, [field.key]: formattedVal || '' }));
- }}
- className="flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors cursor-pointer group"
- >
- <div className="min-w-0 flex-1">
- <p className="text-sm text-white truncate">{field.label}</p>
- <p className="text-xs text-bambu-gray-dark truncate">{field.key}</p>
- </div>
- <div className="flex items-center gap-2 flex-shrink-0">
- {formattedVal && (
- <span className="text-xs text-bambu-gray bg-bambu-dark px-2 py-0.5 rounded max-w-32 truncate" title={formattedVal}>
- {formattedVal.slice(0, 20)}{formattedVal.length > 20 ? '...' : ''}
- </span>
- )}
- <div className="w-6 h-6 flex items-center justify-center rounded bg-bambu-dark-tertiary group-hover:bg-bambu-green/20 transition-colors">
- <Plus className="w-4 h-4 text-bambu-gray group-hover:text-bambu-green transition-colors" />
- </div>
- </div>
- </div>
- );
- })}
- {filteredFields.filter(f => !(f.key in settingsObj)).length === 0 && (
- <p className="text-center text-bambu-gray py-4 text-sm">
- {fieldSearch ? t('profiles.presets.noMatchingFields') : t('profiles.presets.allFieldsAdded')}
- </p>
- )}
- </div>
- {/* Custom field input */}
- <div className="pt-3 mt-3 border-t border-bambu-dark-tertiary flex-shrink-0">
- {showCustomFieldInput ? (
- <div className="flex gap-2">
- <input
- type="text"
- value={customFieldKey}
- onChange={(e) => setCustomFieldKey(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && addCustomField()}
- placeholder="custom_field_name"
- className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
- autoFocus
- />
- <Button size="sm" onClick={addCustomField} disabled={!customFieldKey.trim()}>
- <Plus className="w-4 h-4" />
- </Button>
- <Button size="sm" variant="secondary" onClick={() => { setShowCustomFieldInput(false); setCustomFieldKey(''); }}>
- <X className="w-4 h-4" />
- </Button>
- </div>
- ) : (
- <button
- onClick={() => setShowCustomFieldInput(true)}
- className="w-full flex items-center justify-center gap-2 p-2 text-sm text-bambu-gray hover:text-white border border-dashed border-bambu-dark-tertiary hover:border-bambu-gray-dark rounded-lg transition-colors"
- >
- <Plus className="w-4 h-4" />
- {t('profiles.presets.addCustomField')}
- </button>
- )}
- </div>
- </div>
- {/* Right: Added Fields */}
- <div className="flex flex-col h-full overflow-hidden">
- <div className="flex items-center justify-between mb-3 flex-shrink-0">
- <h3 className="text-sm font-medium text-white">{t('profiles.presets.yourOverrides')}</h3>
- <span className="text-xs text-bambu-gray">
- {t('profiles.templates.fieldsCount', { count: Object.keys(settingsObj).filter(k => k !== 'inherits').length })}
- </span>
- </div>
- <div className="flex-1 overflow-y-auto space-y-2 pr-2 min-h-0">
- {Object.entries(settingsObj)
- .filter(([key]) => key !== 'inherits')
- .map(([key, value]) => {
- const fieldDef = dynamicFields.find(f => f.key === key);
- return (
- <div key={key} className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
- <div className="flex items-center justify-between mb-2">
- <div>
- <p className="text-sm font-medium text-white">{fieldDef?.label || key}</p>
- <p className="text-xs text-bambu-gray-dark">{key}</p>
- </div>
- <button
- onClick={() => updateField(key, undefined)}
- className="p-1 text-bambu-gray hover:text-red-400 transition-colors"
- >
- <X className="w-4 h-4" />
- </button>
- </div>
- {fieldDef ? (
- renderFieldInput(fieldDef)
- ) : (
- <input
- type="text"
- value={String(value)}
- onChange={(e) => updateField(key, e.target.value)}
- className="w-full px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
- />
- )}
- </div>
- );
- })}
- {Object.keys(settingsObj).filter(k => k !== 'inherits').length === 0 && (
- <div className="text-center py-8 text-bambu-gray">
- <Sliders className="w-8 h-8 mx-auto mb-2 opacity-50" />
- <p className="text-sm">{t('profiles.presets.noOverridesYet')}</p>
- <p className="text-xs mt-1">{t('profiles.presets.clickFieldsToAdd')}</p>
- </div>
- )}
- </div>
- {/* Save as template button */}
- {Object.keys(settingsObj).filter(k => k !== 'inherits').length > 0 && (
- <div className="pt-3 mt-3 border-t border-bambu-dark-tertiary flex-shrink-0">
- <button
- onClick={() => { setShowSaveTemplate(true); setActiveTab('common'); }}
- className="w-full flex items-center justify-center gap-2 p-2 text-sm text-bambu-gray hover:text-white border border-dashed border-bambu-dark-tertiary hover:border-bambu-gray-dark rounded-lg transition-colors"
- >
- <Save className="w-4 h-4" />
- {t('profiles.presets.saveAsTemplate')}
- </button>
- </div>
- )}
- </div>
- </div>
- )}
- {activeTab === 'json' && (
- <div className="space-y-2">
- {jsonError && (
- <div className="flex items-center gap-2 text-red-400 text-sm">
- <AlertCircle className="w-4 h-4" />
- {jsonError}
- </div>
- )}
- <textarea
- value={jsonText}
- onChange={(e) => handleJsonChange(e.target.value)}
- className={`w-full h-80 px-3 py-2 bg-bambu-dark border rounded-lg text-white text-xs font-mono focus:outline-none resize-none ${
- jsonError ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'
- }`}
- spellCheck={false}
- />
- <p className="text-xs text-bambu-gray">
- {t('profiles.presets.jsonTip')}
- </p>
- </div>
- )}
- </div>
- {/* Footer */}
- <div className="p-4 border-t border-bambu-dark-tertiary flex gap-2">
- <Button variant="secondary" onClick={onClose} className="flex-1">{t('common.cancel')}</Button>
- <Button
- onClick={() => saveMutation.mutate()}
- disabled={saveMutation.isPending || !name.trim() || (!isEditMode && !baseId) || !!jsonError}
- className="flex-1"
- >
- {saveMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : (isEditMode ? <Save className="w-4 h-4" /> : <Plus className="w-4 h-4" />)}
- {isEditMode ? t('common.save') : (initialData ? t('common.duplicate') : t('common.create'))}
- </Button>
- </div>
- </CardContent>
- </Card>
- </div>
- );
- }
- // ============================================================================
- // CLOUD PROFILES VIEW
- // ============================================================================
- function CloudProfilesView({
- settings,
- lastSyncTime,
- onRefresh,
- isRefreshing,
- printers,
- hasPermission,
- t,
- }: {
- settings: SlicerSettingsResponse;
- lastSyncTime?: Date;
- onRefresh: () => void;
- isRefreshing: boolean;
- printers: Printer[];
- hasPermission: (permission: Permission) => boolean;
- t: TFunction;
- }) {
- const [searchQuery, setSearchQuery] = useState('');
- const [filterType, setFilterType] = useState<PresetType>('all');
- const [filterOwner, setFilterOwner] = useState<'all' | 'custom' | 'builtin'>('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<SlicerSetting | null>(null);
- const [showCreateModal, setShowCreateModal] = useState(false);
- const [showTemplatesModal, setShowTemplatesModal] = useState(false);
- const [duplicateData, setDuplicateData] = useState<{ type: string; name: string; base_id: string; setting: Record<string, unknown> } | null>(null);
- const [editData, setEditData] = useState<{ type: string; name: string; base_id: string; setting: Record<string, unknown>; setting_id: string } | null>(null);
- const [templateData, setTemplateData] = useState<{ type: string; setting: Record<string, unknown> } | null>(null);
- // Compare mode state
- const [compareMode, setCompareMode] = useState(false);
- const [compareSelection, setCompareSelection] = useState<[SlicerSetting | null, SlicerSetting | null]>([null, null]);
- const [showCompareModal, setShowCompareModal] = useState(false);
- const [comparePresets, setComparePresets] = useState<[Record<string, unknown>, Record<string, unknown>] | null>(null);
- const queryClient = useQueryClient();
- // Combine all presets with metadata
- 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]);
- // Extract unique filter values (use configured printers from API)
- 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]);
- // Get selected printer's model for filtering
- const selectedPrinterModel = useMemo(() => {
- if (filterPrinter === 'all') return null;
- const printer = printers.find(p => p.id.toString() === filterPrinter);
- return printer?.model || null;
- }, [filterPrinter, printers]);
- // Apply filters
- const filteredPresets = useMemo(() => {
- return allPresetsWithMeta
- .filter(s => filterType === 'all' || s.type === filterType)
- .filter(s => {
- if (filterOwner === 'all') return true;
- const isCustom = isUserPreset(s.setting_id);
- return filterOwner === 'custom' ? isCustom : !isCustom;
- })
- .filter(s => {
- if (filterPrinter === 'all' || !selectedPrinterModel) return true;
- // Match preset's printer model to configured printer's model
- 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, filterOwner, filterPrinter, selectedPrinterModel, filterNozzle, filterFilament, filterLayerHeight, searchQuery]);
- // Handle click on preset in compare mode
- const handlePresetClick = (preset: SlicerSetting) => {
- if (compareMode) {
- // In compare mode, toggle selection
- const isFirst = compareSelection[0]?.setting_id === preset.setting_id;
- const isSecond = compareSelection[1]?.setting_id === preset.setting_id;
- if (isFirst) {
- // Deselect first
- setCompareSelection([compareSelection[1], null]);
- } else if (isSecond) {
- // Deselect second
- setCompareSelection([compareSelection[0], null]);
- } else if (!compareSelection[0]) {
- // Select as first
- setCompareSelection([preset, null]);
- } else if (!compareSelection[1]) {
- // Check type match - only allow same type
- if (compareSelection[0].type !== preset.type) {
- return; // Don't allow selecting different types
- }
- // Select as second
- setCompareSelection([compareSelection[0], preset]);
- } else {
- // Both selected, replace second (must match first's type)
- if (compareSelection[0].type !== preset.type) {
- return;
- }
- setCompareSelection([compareSelection[0], preset]);
- }
- } else {
- // Normal mode, open detail
- setSelectedSetting(preset);
- }
- };
- // Check if preset is selected for comparison
- const getCompareIndex = (preset: SlicerSetting): number | undefined => {
- if (compareSelection[0]?.setting_id === preset.setting_id) return 0;
- if (compareSelection[1]?.setting_id === preset.setting_id) return 1;
- return undefined;
- };
- const handleDuplicate = async (setting: SlicerSetting) => {
- try {
- // Always fetch fresh data (bypass cache)
- const detail = await api.getCloudSettingDetail(setting.setting_id);
- const apiType = setting.type === 'process' ? 'print' : setting.type;
- setDuplicateData({
- type: apiType,
- name: setting.name,
- base_id: detail.base_id || 'GFSA00',
- setting: detail.setting || {},
- });
- setSelectedSetting(null);
- } catch (error) {
- console.error('Failed to fetch preset details for duplication:', error);
- }
- };
- const handleEdit = async (setting: SlicerSetting) => {
- try {
- // Clear any cached data first
- queryClient.removeQueries({ queryKey: ['cloudSettingDetail', setting.setting_id] });
- // Always fetch fresh data (bypass cache)
- const detail = await api.getCloudSettingDetail(setting.setting_id);
- const apiType = setting.type === 'process' ? 'print' : setting.type;
- setEditData({
- type: apiType,
- name: setting.name,
- base_id: detail.base_id || 'GFSA00',
- setting: detail.setting || {},
- setting_id: setting.setting_id,
- });
- setSelectedSetting(null);
- } catch (error) {
- console.error('Failed to fetch preset details for editing:', error);
- }
- };
- const clearFilters = () => {
- setFilterType('all');
- setFilterOwner('all');
- setFilterPrinter('all');
- setFilterNozzle('all');
- setFilterFilament('all');
- setFilterLayerHeight('all');
- setSearchQuery('');
- };
- const hasActiveFilters = filterType !== 'all' || filterOwner !== 'all' || filterPrinter !== 'all' || filterNozzle !== 'all' ||
- filterFilament !== 'all' || filterLayerHeight !== 'all' || searchQuery !== '';
- const totalCount = settings.filament.length + settings.printer.length + settings.process.length;
- return (
- <>
- {/* Search and Filters */}
- <div className="space-y-4 mb-6">
- {/* Search row */}
- <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={compareMode ? 'primary' : 'secondary'}
- onClick={() => {
- if (compareMode) {
- setCompareMode(false);
- setCompareSelection([null, null]);
- } else {
- setCompareMode(true);
- }
- }}
- >
- <GitCompare className="w-4 h-4" />
- {compareMode ? t('common.cancel') : t('profiles.presets.compare')}
- </Button>
- <Button
- variant="secondary"
- onClick={() => setShowTemplatesModal(true)}
- disabled={!hasPermission('cloud:auth')}
- title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noTemplatesPermission') : undefined}
- >
- <Sparkles className="w-4 h-4" />
- {t('profiles.cloudView.templates')}
- </Button>
- <Button
- variant="secondary"
- onClick={onRefresh}
- disabled={isRefreshing || !hasPermission('cloud:auth')}
- title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noRefreshPermission') : undefined}
- >
- <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
- {t('profiles.cloudView.refresh')}
- </Button>
- <Button
- onClick={() => setShowCreateModal(true)}
- disabled={!hasPermission('cloud:auth')}
- title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noCreatePermission') : undefined}
- >
- <Plus className="w-4 h-4" />
- {t('profiles.cloudView.newPreset')}
- </Button>
- </div>
- </div>
- {/* Filter row */}
- <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 PresetType)}
- />
- <FilterDropdown
- label={t('profiles.cloudView.filters.owner')}
- value={filterOwner}
- options={[
- { value: 'all', label: t('profiles.cloudView.filters.all') },
- { value: 'custom', label: t('profiles.cloudView.filters.myPresets') },
- { value: 'builtin', label: t('profiles.cloudView.filters.builtIn') },
- ]}
- onChange={(v) => setFilterOwner(v as 'all' | 'custom' | 'builtin')}
- />
- {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>
- {/* Compare mode bar */}
- {compareMode && (
- <div className="mb-4 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <GitCompare className="w-5 h-5 text-blue-400" />
- <span className="text-white font-medium">{t('profiles.cloudView.compareMode')}</span>
- <span className="text-bambu-gray">
- {compareSelection[0]
- ? t('profiles.cloudView.selectAnotherPreset', { type: compareSelection[0].type })
- : t('profiles.cloudView.clickTwoPresets')}
- </span>
- </div>
- <div className="flex items-center gap-3">
- <div className="flex items-center gap-2">
- <span className={`px-2 py-1 text-sm rounded truncate max-w-[200px] ${compareSelection[0] ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-bambu-dark text-bambu-gray'}`}>
- {compareSelection[0] ? compareSelection[0].name : t('profiles.cloudView.selectFirst')}
- </span>
- <ArrowRight className="w-4 h-4 text-bambu-gray" />
- <span className={`px-2 py-1 text-sm rounded truncate max-w-[200px] ${compareSelection[1] ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-bambu-dark text-bambu-gray'}`}>
- {compareSelection[1] ? compareSelection[1].name : t('profiles.cloudView.selectSecond')}
- </span>
- </div>
- {compareSelection[0] && compareSelection[1] && (
- <Button
- size="sm"
- onClick={async () => {
- try {
- const [left, right] = await Promise.all([
- api.getCloudSettingDetail(compareSelection[0]!.setting_id),
- api.getCloudSettingDetail(compareSelection[1]!.setting_id),
- ]);
- setComparePresets([
- (left.setting || {}) as Record<string, unknown>,
- (right.setting || {}) as Record<string, unknown>,
- ]);
- setShowCompareModal(true);
- } catch {
- // Handle error silently
- }
- }}
- >
- <GitCompare className="w-4 h-4" />
- {t('profiles.cloudView.compareNow')}
- </Button>
- )}
- </div>
- </div>
- </div>
- )}
- {/* Status row: sync time, count, and legend */}
- <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(), t)}
- </div>
- )}
- <span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>
- <div className="flex items-center gap-1">
- <span className="w-1.5 h-1.5 rounded-full bg-bambu-green" />
- <span>= {t('profiles.presets.myPreset')}</span>
- </div>
- </div>
- {/* 3-Column Presets List */}
- {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">
- {/* Filament Column */}
- <div>
- <div className="flex items-center gap-2 mb-3 px-1">
- <Droplet className="w-4 h-4 text-amber-400" />
- <h3 className="text-sm font-medium text-bambu-gray">{t('profiles.cloudView.columns.filament')}</h3>
- <span className="text-xs text-bambu-gray-dark">
- ({filteredPresets.filter(p => p.type === 'filament').length})
- </span>
- </div>
- <div className="space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1">
- {filteredPresets
- .filter(p => p.type === 'filament')
- .map((preset) => (
- <PresetListItem
- key={preset.setting_id}
- setting={preset}
- onClick={() => handlePresetClick(preset)}
- onDuplicate={() => handleDuplicate(preset)}
- compareMode={compareMode}
- isCompareSelected={getCompareIndex(preset) !== undefined}
- compareIndex={getCompareIndex(preset)}
- compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}
- t={t}
- />
- ))}
- {filteredPresets.filter(p => p.type === 'filament').length === 0 && (
- <p className="text-xs text-bambu-gray-dark px-3 py-2">{t('profiles.cloudView.noFilamentPresets')}</p>
- )}
- </div>
- </div>
- {/* Process Column */}
- <div>
- <div className="flex items-center gap-2 mb-3 px-1">
- <Settings2 className="w-4 h-4 text-blue-400" />
- <h3 className="text-sm font-medium text-bambu-gray">{t('profiles.cloudView.columns.process')}</h3>
- <span className="text-xs text-bambu-gray-dark">
- ({filteredPresets.filter(p => p.type === 'process').length})
- </span>
- </div>
- <div className="space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1">
- {filteredPresets
- .filter(p => p.type === 'process')
- .map((preset) => (
- <PresetListItem
- key={preset.setting_id}
- setting={preset}
- onClick={() => handlePresetClick(preset)}
- onDuplicate={() => handleDuplicate(preset)}
- compareMode={compareMode}
- isCompareSelected={getCompareIndex(preset) !== undefined}
- compareIndex={getCompareIndex(preset)}
- compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}
- t={t}
- />
- ))}
- {filteredPresets.filter(p => p.type === 'process').length === 0 && (
- <p className="text-xs text-bambu-gray-dark px-3 py-2">{t('profiles.cloudView.noProcessPresets')}</p>
- )}
- </div>
- </div>
- {/* Printer Column */}
- <div>
- <div className="flex items-center gap-2 mb-3 px-1">
- <PrinterIcon className="w-4 h-4 text-purple-400" />
- <h3 className="text-sm font-medium text-bambu-gray">{t('profiles.cloudView.columns.printer')}</h3>
- <span className="text-xs text-bambu-gray-dark">
- ({filteredPresets.filter(p => p.type === 'printer').length})
- </span>
- </div>
- <div className="space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1">
- {filteredPresets
- .filter(p => p.type === 'printer')
- .map((preset) => (
- <PresetListItem
- key={preset.setting_id}
- setting={preset}
- onClick={() => handlePresetClick(preset)}
- onDuplicate={() => handleDuplicate(preset)}
- compareMode={compareMode}
- isCompareSelected={getCompareIndex(preset) !== undefined}
- compareIndex={getCompareIndex(preset)}
- compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}
- t={t}
- />
- ))}
- {filteredPresets.filter(p => p.type === 'printer').length === 0 && (
- <p className="text-xs text-bambu-gray-dark px-3 py-2">{t('profiles.cloudView.noPrinterPresets')}</p>
- )}
- </div>
- </div>
- </div>
- )}
- {/* Modals */}
- {selectedSetting && (
- <PresetDetailModal
- setting={selectedSetting}
- onClose={() => setSelectedSetting(null)}
- onDeleted={() => setSelectedSetting(null)}
- onDuplicate={() => handleDuplicate(selectedSetting)}
- onEdit={() => handleEdit(selectedSetting)}
- hasPermission={hasPermission}
- t={t}
- />
- )}
- {(showCreateModal || duplicateData || editData || templateData) && (
- <CreatePresetModal
- onClose={() => { setShowCreateModal(false); setDuplicateData(null); setEditData(null); setTemplateData(null); }}
- initialData={editData || duplicateData || (templateData ? { type: templateData.type, name: '', base_id: '', setting: templateData.setting } : undefined)}
- allPresets={settings}
- t={t}
- />
- )}
- {showTemplatesModal && (
- <TemplatesModal
- onClose={() => setShowTemplatesModal(false)}
- onApply={(template) => {
- setTemplateData({ type: template.type, setting: template.settings });
- setShowTemplatesModal(false);
- }}
- t={t}
- />
- )}
- {showCompareModal && comparePresets && compareSelection[0] && compareSelection[1] && (
- <DiffModal
- onClose={() => {
- setShowCompareModal(false);
- setComparePresets(null);
- }}
- leftPreset={comparePresets[0]}
- rightPreset={comparePresets[1]}
- leftLabel={compareSelection[0].name}
- rightLabel={compareSelection[1].name}
- t={t}
- />
- )}
- </>
- );
- }
- // ============================================================================
- // MAIN PAGE
- // ============================================================================
- export function ProfilesPage() {
- const { t } = useTranslation();
- const queryClient = useQueryClient();
- const { showToast } = useToast();
- const { hasPermission } = useAuth();
- const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');
- const [lastSyncTime, setLastSyncTime] = useState<Date>();
- const { data: status, isLoading: statusLoading } = useQuery({
- queryKey: ['cloudStatus'],
- queryFn: api.getCloudStatus,
- });
- const { data: printers = [] } = useQuery({
- queryKey: ['printers'],
- queryFn: api.getPrinters,
- });
- const { data: settings, isLoading: settingsLoading, refetch: refetchSettings, dataUpdatedAt } = useQuery({
- queryKey: ['cloudSettings'],
- queryFn: () => api.getCloudSettings(),
- enabled: !!status?.is_authenticated,
- retry: false,
- staleTime: 1000 * 60 * 5,
- });
- useEffect(() => {
- if (dataUpdatedAt) {
- setLastSyncTime(new Date(dataUpdatedAt));
- }
- }, [dataUpdatedAt]);
- const logoutMutation = useMutation({
- mutationFn: api.cloudLogout,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
- queryClient.removeQueries({ queryKey: ['cloudSettings'] });
- showToast(t('profiles.toast.loggedOut'));
- },
- });
- const handleLoginSuccess = () => {
- queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
- };
- if (statusLoading) {
- return (
- <div className="p-4 md:p-8 flex items-center justify-center min-h-[400px]">
- <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
- </div>
- );
- }
- return (
- <div className="p-6 lg:p-8">
- {/* Page Header */}
- <div className="mb-6">
- <h1 className="text-2xl font-bold text-white">{t('profiles.title')}</h1>
- <p className="text-bambu-gray">{t('profiles.subtitle')}</p>
- </div>
- {/* Tab Navigation */}
- <div className="flex border-b border-bambu-dark-tertiary mb-6">
- <button
- onClick={() => setActiveTab('cloud')}
- className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
- activeTab === 'cloud'
- ? 'text-bambu-green border-bambu-green'
- : 'text-bambu-gray hover:text-white border-transparent'
- }`}
- >
- <Cloud className="w-4 h-4" />
- {t('profiles.tabs.cloud')}
- </button>
- <button
- onClick={() => setActiveTab('local')}
- className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
- activeTab === 'local'
- ? 'text-bambu-green border-bambu-green'
- : 'text-bambu-gray hover:text-white border-transparent'
- }`}
- >
- <HardDrive className="w-4 h-4" />
- {t('profiles.tabs.local')}
- </button>
- <button
- onClick={() => setActiveTab('kprofiles')}
- className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
- activeTab === 'kprofiles'
- ? 'text-bambu-green border-bambu-green'
- : 'text-bambu-gray hover:text-white border-transparent'
- }`}
- >
- <Gauge className="w-4 h-4" />
- {t('profiles.tabs.kprofiles')}
- </button>
- </div>
- {/* Cloud Profiles Tab */}
- {activeTab === 'cloud' && (
- <>
- {/* Connection Status Bar */}
- {status?.is_authenticated && (
- <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.connectedAs')} <span className="text-white">{status.email}</span>
- </span>
- </div>
- <Button
- variant="secondary"
- size="sm"
- onClick={() => logoutMutation.mutate()}
- disabled={logoutMutation.isPending || !hasPermission('cloud:auth')}
- title={!hasPermission('cloud:auth') ? t('profiles.noLogoutPermission') : undefined}
- >
- <LogOut className="w-4 h-4" />
- {t('profiles.logout')}
- </Button>
- </div>
- )}
- {!status?.is_authenticated ? (
- <LoginForm onSuccess={handleLoginSuccess} t={t} />
- ) : settingsLoading ? (
- <div className="flex items-center justify-center py-16">
- <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
- </div>
- ) : settings ? (
- <CloudProfilesView
- settings={settings}
- lastSyncTime={lastSyncTime}
- onRefresh={() => refetchSettings()}
- isRefreshing={settingsLoading}
- printers={printers}
- hasPermission={hasPermission}
- t={t}
- />
- ) : (
- <div className="text-center py-16">
- <p className="text-bambu-gray mb-4">{t('profiles.failedToLoad')}</p>
- <Button onClick={() => refetchSettings()}>{t('profiles.retry')}</Button>
- </div>
- )}
- </>
- )}
- {/* Local Profiles Tab */}
- {activeTab === 'local' && <LocalProfilesView />}
- {/* K-Profiles Tab */}
- {activeTab === 'kprofiles' && <KProfilesView />}
- {/* Scroll to Top Button */}
- <ScrollToTop />
- </div>
- );
- }
|