ProfilesPage.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. import { useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import {
  4. Cloud,
  5. LogIn,
  6. LogOut,
  7. Loader2,
  8. ChevronDown,
  9. ChevronRight,
  10. Settings2,
  11. Printer,
  12. Droplet,
  13. X,
  14. Key,
  15. RefreshCw,
  16. Gauge,
  17. } from 'lucide-react';
  18. import { api } from '../api/client';
  19. import type { SlicerSetting, SlicerSettingsResponse } from '../api/client';
  20. import { Card, CardContent, CardHeader } from '../components/Card';
  21. import { Button } from '../components/Button';
  22. import { useToast } from '../contexts/ToastContext';
  23. import { KProfilesView } from '../components/KProfilesView';
  24. type ProfileTab = 'cloud' | 'kprofiles';
  25. type LoginStep = 'email' | 'code' | 'token';
  26. function LoginForm({ onSuccess }: { onSuccess: () => void }) {
  27. const { showToast } = useToast();
  28. const [step, setStep] = useState<LoginStep>('email');
  29. const [email, setEmail] = useState('');
  30. const [password, setPassword] = useState('');
  31. const [code, setCode] = useState('');
  32. const [token, setToken] = useState('');
  33. const [region, setRegion] = useState('global');
  34. const loginMutation = useMutation({
  35. mutationFn: () => api.cloudLogin(email, password, region),
  36. onSuccess: (result) => {
  37. if (result.success) {
  38. showToast('Logged in successfully');
  39. onSuccess();
  40. } else if (result.needs_verification) {
  41. showToast('Verification code sent to your email');
  42. setStep('code');
  43. } else {
  44. showToast(result.message, 'error');
  45. }
  46. },
  47. onError: (error: Error) => {
  48. showToast(error.message, 'error');
  49. },
  50. });
  51. const verifyMutation = useMutation({
  52. mutationFn: () => api.cloudVerify(email, code),
  53. onSuccess: (result) => {
  54. if (result.success) {
  55. showToast('Logged in successfully');
  56. onSuccess();
  57. } else {
  58. showToast(result.message, 'error');
  59. }
  60. },
  61. onError: (error: Error) => {
  62. showToast(error.message, 'error');
  63. },
  64. });
  65. const tokenMutation = useMutation({
  66. mutationFn: () => api.cloudSetToken(token),
  67. onSuccess: () => {
  68. showToast('Token set successfully');
  69. onSuccess();
  70. },
  71. onError: (error: Error) => {
  72. showToast(error.message, 'error');
  73. },
  74. });
  75. const handleSubmit = (e: React.FormEvent) => {
  76. e.preventDefault();
  77. if (step === 'email') {
  78. loginMutation.mutate();
  79. } else if (step === 'code') {
  80. verifyMutation.mutate();
  81. } else if (step === 'token') {
  82. tokenMutation.mutate();
  83. }
  84. };
  85. const isPending = loginMutation.isPending || verifyMutation.isPending || tokenMutation.isPending;
  86. return (
  87. <Card className="max-w-md mx-auto">
  88. <CardHeader>
  89. <div className="flex items-center gap-2">
  90. <Cloud className="w-5 h-5 text-bambu-green" />
  91. <h2 className="text-xl font-semibold text-white">Connect to Bambu Cloud</h2>
  92. </div>
  93. </CardHeader>
  94. <CardContent>
  95. <form onSubmit={handleSubmit} className="space-y-4">
  96. {step === 'email' && (
  97. <>
  98. <div>
  99. <label className="block text-sm text-bambu-gray mb-1">Email</label>
  100. <input
  101. type="email"
  102. value={email}
  103. onChange={(e) => setEmail(e.target.value)}
  104. 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"
  105. placeholder="your@email.com"
  106. required
  107. />
  108. </div>
  109. <div>
  110. <label className="block text-sm text-bambu-gray mb-1">Password</label>
  111. <input
  112. type="password"
  113. value={password}
  114. onChange={(e) => setPassword(e.target.value)}
  115. 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"
  116. placeholder="••••••••"
  117. required
  118. />
  119. </div>
  120. <div>
  121. <label className="block text-sm text-bambu-gray mb-1">Region</label>
  122. <select
  123. value={region}
  124. onChange={(e) => setRegion(e.target.value)}
  125. 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"
  126. >
  127. <option value="global">Global</option>
  128. <option value="china">China</option>
  129. </select>
  130. </div>
  131. </>
  132. )}
  133. {step === 'code' && (
  134. <div>
  135. <label className="block text-sm text-bambu-gray mb-1">
  136. Verification Code
  137. </label>
  138. <p className="text-xs text-bambu-gray mb-2">
  139. Check your email ({email}) for a 6-digit code
  140. </p>
  141. <input
  142. type="text"
  143. value={code}
  144. onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
  145. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-center text-2xl tracking-widest focus:border-bambu-green focus:outline-none"
  146. placeholder="000000"
  147. maxLength={6}
  148. required
  149. />
  150. </div>
  151. )}
  152. {step === 'token' && (
  153. <div>
  154. <label className="block text-sm text-bambu-gray mb-1">
  155. Access Token
  156. </label>
  157. <p className="text-xs text-bambu-gray mb-2">
  158. Paste your Bambu Lab access token (from Bambu Studio)
  159. </p>
  160. <textarea
  161. value={token}
  162. onChange={(e) => setToken(e.target.value)}
  163. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:border-bambu-green focus:outline-none resize-none"
  164. placeholder="eyJ..."
  165. rows={3}
  166. required
  167. />
  168. </div>
  169. )}
  170. <div className="flex gap-2">
  171. {step === 'code' && (
  172. <Button
  173. type="button"
  174. variant="secondary"
  175. onClick={() => setStep('email')}
  176. className="flex-1"
  177. >
  178. Back
  179. </Button>
  180. )}
  181. <Button type="submit" disabled={isPending} className="flex-1">
  182. {isPending ? (
  183. <Loader2 className="w-4 h-4 animate-spin" />
  184. ) : (
  185. <LogIn className="w-4 h-4" />
  186. )}
  187. {step === 'email' ? 'Login' : step === 'code' ? 'Verify' : 'Set Token'}
  188. </Button>
  189. </div>
  190. {step === 'email' && (
  191. <div className="pt-4 border-t border-bambu-dark-tertiary">
  192. <button
  193. type="button"
  194. onClick={() => setStep('token')}
  195. className="text-sm text-bambu-gray hover:text-white flex items-center gap-1"
  196. >
  197. <Key className="w-3 h-3" />
  198. Use access token instead
  199. </button>
  200. </div>
  201. )}
  202. {step === 'token' && (
  203. <div className="pt-4 border-t border-bambu-dark-tertiary">
  204. <button
  205. type="button"
  206. onClick={() => setStep('email')}
  207. className="text-sm text-bambu-gray hover:text-white flex items-center gap-1"
  208. >
  209. <LogIn className="w-3 h-3" />
  210. Login with email instead
  211. </button>
  212. </div>
  213. )}
  214. </form>
  215. </CardContent>
  216. </Card>
  217. );
  218. }
  219. function SettingCard({
  220. setting,
  221. onClick,
  222. }: {
  223. setting: SlicerSetting;
  224. onClick: () => void;
  225. }) {
  226. return (
  227. <button
  228. onClick={onClick}
  229. className="w-full text-left p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  230. >
  231. <p className="text-white font-medium truncate">{setting.name}</p>
  232. {setting.updated_time && (
  233. <p className="text-xs text-bambu-gray mt-1">
  234. Updated: {new Date(setting.updated_time).toLocaleDateString()}
  235. </p>
  236. )}
  237. </button>
  238. );
  239. }
  240. function SettingDetailModal({
  241. setting,
  242. onClose,
  243. }: {
  244. setting: SlicerSetting;
  245. onClose: () => void;
  246. }) {
  247. const { data: detail, isLoading } = useQuery({
  248. queryKey: ['cloudSettingDetail', setting.setting_id],
  249. queryFn: () => api.getCloudSettingDetail(setting.setting_id),
  250. });
  251. return (
  252. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  253. <Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
  254. <CardContent className="p-0 flex flex-col h-full">
  255. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  256. <div>
  257. <h2 className="text-xl font-semibold text-white">{setting.name}</h2>
  258. <p className="text-sm text-bambu-gray capitalize">{setting.type} preset</p>
  259. </div>
  260. <button
  261. onClick={onClose}
  262. className="text-bambu-gray hover:text-white transition-colors"
  263. >
  264. <X className="w-5 h-5" />
  265. </button>
  266. </div>
  267. <div className="flex-1 overflow-y-auto p-4">
  268. {isLoading ? (
  269. <div className="flex justify-center py-8">
  270. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  271. </div>
  272. ) : detail ? (
  273. <pre className="text-xs text-bambu-gray font-mono whitespace-pre-wrap overflow-x-auto bg-bambu-dark p-4 rounded-lg">
  274. {JSON.stringify(detail, null, 2)}
  275. </pre>
  276. ) : (
  277. <p className="text-bambu-gray text-center py-8">
  278. Failed to load preset details
  279. </p>
  280. )}
  281. </div>
  282. <div className="p-4 border-t border-bambu-dark-tertiary">
  283. <Button variant="secondary" onClick={onClose} className="w-full">
  284. Close
  285. </Button>
  286. </div>
  287. </CardContent>
  288. </Card>
  289. </div>
  290. );
  291. }
  292. function ProfilesView({ settings }: { settings: SlicerSettingsResponse }) {
  293. const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
  294. const [selectedSetting, setSelectedSetting] = useState<SlicerSetting | null>(null);
  295. // Sort items alphabetically by name
  296. const sortByName = (items: SlicerSetting[]) =>
  297. [...items].sort((a, b) => a.name.localeCompare(b.name));
  298. const toggleSection = (section: string) => {
  299. setExpandedSections((prev) => {
  300. const next = new Set(prev);
  301. if (next.has(section)) {
  302. next.delete(section);
  303. } else {
  304. next.add(section);
  305. }
  306. return next;
  307. });
  308. };
  309. const sections = [
  310. {
  311. key: 'filament',
  312. label: 'Filament Presets',
  313. icon: Droplet,
  314. items: sortByName(settings.filament),
  315. },
  316. {
  317. key: 'printer',
  318. label: 'Printer Presets',
  319. icon: Printer,
  320. items: sortByName(settings.printer),
  321. },
  322. {
  323. key: 'process',
  324. label: 'Process Presets',
  325. icon: Settings2,
  326. items: sortByName(settings.process),
  327. },
  328. ];
  329. return (
  330. <>
  331. <div className="space-y-4">
  332. {sections.map(({ key, label, icon: Icon, items }) => (
  333. <Card key={key}>
  334. <button
  335. onClick={() => toggleSection(key)}
  336. className="w-full flex items-center justify-between p-4"
  337. >
  338. <div className="flex items-center gap-3">
  339. <Icon className="w-5 h-5 text-bambu-green" />
  340. <span className="text-lg font-semibold text-white">{label}</span>
  341. <span className="text-sm text-bambu-gray">({items.length})</span>
  342. </div>
  343. {expandedSections.has(key) ? (
  344. <ChevronDown className="w-5 h-5 text-bambu-gray" />
  345. ) : (
  346. <ChevronRight className="w-5 h-5 text-bambu-gray" />
  347. )}
  348. </button>
  349. {expandedSections.has(key) && items.length > 0 && (
  350. <CardContent className="pt-0">
  351. <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
  352. {items.map((item) => (
  353. <SettingCard
  354. key={item.setting_id}
  355. setting={item}
  356. onClick={() => setSelectedSetting(item)}
  357. />
  358. ))}
  359. </div>
  360. </CardContent>
  361. )}
  362. {expandedSections.has(key) && items.length === 0 && (
  363. <CardContent className="pt-0">
  364. <p className="text-bambu-gray text-sm">No presets found</p>
  365. </CardContent>
  366. )}
  367. </Card>
  368. ))}
  369. </div>
  370. {selectedSetting && (
  371. <SettingDetailModal
  372. setting={selectedSetting}
  373. onClose={() => setSelectedSetting(null)}
  374. />
  375. )}
  376. </>
  377. );
  378. }
  379. export function ProfilesPage() {
  380. const queryClient = useQueryClient();
  381. const { showToast } = useToast();
  382. const [activeTab, setActiveTab] = useState<ProfileTab>('kprofiles');
  383. const { data: status, isLoading: statusLoading } = useQuery({
  384. queryKey: ['cloudStatus'],
  385. queryFn: api.getCloudStatus,
  386. });
  387. const { data: settings, isLoading: settingsLoading, refetch: refetchSettings } = useQuery({
  388. queryKey: ['cloudSettings'],
  389. queryFn: () => api.getCloudSettings(),
  390. enabled: !!status?.is_authenticated,
  391. retry: false,
  392. staleTime: 1000 * 60 * 5, // 5 minutes
  393. });
  394. const logoutMutation = useMutation({
  395. mutationFn: api.cloudLogout,
  396. onSuccess: () => {
  397. queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
  398. queryClient.removeQueries({ queryKey: ['cloudSettings'] });
  399. showToast('Logged out');
  400. },
  401. });
  402. const handleLoginSuccess = () => {
  403. queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
  404. };
  405. if (statusLoading) {
  406. return (
  407. <div className="p-8 flex justify-center">
  408. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  409. </div>
  410. );
  411. }
  412. return (
  413. <div className="p-8">
  414. {/* Page Header */}
  415. <div className="mb-6">
  416. <h1 className="text-2xl font-bold text-white">Profiles</h1>
  417. <p className="text-bambu-gray">
  418. Manage your slicer presets and pressure advance calibrations
  419. </p>
  420. </div>
  421. {/* Tab Navigation */}
  422. <div className="flex border-b border-bambu-dark-tertiary mb-6">
  423. <button
  424. onClick={() => setActiveTab('cloud')}
  425. className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
  426. activeTab === 'cloud'
  427. ? 'text-bambu-green border-bambu-green'
  428. : 'text-bambu-gray hover:text-white border-transparent'
  429. }`}
  430. >
  431. <Cloud className="w-4 h-4" />
  432. Cloud Profiles
  433. </button>
  434. <button
  435. onClick={() => setActiveTab('kprofiles')}
  436. className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
  437. activeTab === 'kprofiles'
  438. ? 'text-bambu-green border-bambu-green'
  439. : 'text-bambu-gray hover:text-white border-transparent'
  440. }`}
  441. >
  442. <Gauge className="w-4 h-4" />
  443. K-Profiles
  444. </button>
  445. </div>
  446. {/* Cloud Profiles Tab */}
  447. {activeTab === 'cloud' && (
  448. <>
  449. {/* Cloud Status Header */}
  450. <div className="mb-6 flex items-center justify-between">
  451. <p className="text-bambu-gray">
  452. {status?.is_authenticated
  453. ? `Connected as ${status.email}`
  454. : 'Connect to Bambu Cloud to access your slicer presets'}
  455. </p>
  456. {status?.is_authenticated && (
  457. <div className="flex gap-2">
  458. <Button
  459. variant="secondary"
  460. onClick={() => refetchSettings()}
  461. disabled={settingsLoading}
  462. >
  463. <RefreshCw className={`w-4 h-4 ${settingsLoading ? 'animate-spin' : ''}`} />
  464. Refresh
  465. </Button>
  466. <Button
  467. variant="secondary"
  468. onClick={() => logoutMutation.mutate()}
  469. disabled={logoutMutation.isPending}
  470. >
  471. <LogOut className="w-4 h-4" />
  472. Logout
  473. </Button>
  474. </div>
  475. )}
  476. </div>
  477. {!status?.is_authenticated ? (
  478. <LoginForm onSuccess={handleLoginSuccess} />
  479. ) : settingsLoading ? (
  480. <div className="flex justify-center py-12">
  481. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  482. </div>
  483. ) : settings ? (
  484. <ProfilesView settings={settings} />
  485. ) : (
  486. <Card>
  487. <CardContent className="py-8 text-center">
  488. <p className="text-bambu-gray">Failed to load profiles</p>
  489. <Button className="mt-4" onClick={() => refetchSettings()}>
  490. Retry
  491. </Button>
  492. </CardContent>
  493. </Card>
  494. )}
  495. </>
  496. )}
  497. {/* K-Profiles Tab */}
  498. {activeTab === 'kprofiles' && <KProfilesView />}
  499. </div>
  500. );
  501. }