CloudProfilesPage.tsx 16 KB

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