SlicerBundlesPanel.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import { Loader2, Package, Trash2, Upload } from 'lucide-react';
  5. import { api, type SlicerBundle } from '../api/client';
  6. import { Card, CardContent, CardHeader } from './Card';
  7. import { Button } from './Button';
  8. import { ConfirmModal } from './ConfirmModal';
  9. import { useToast } from '../contexts/ToastContext';
  10. // Settings panel for managing BambuStudio "Printer Preset Bundles"
  11. // (.bbscfg) on the slicer sidecar. Sits below the slicer-API URL panel
  12. // in SettingsPage and is hidden when use_slicer_api is off — without a
  13. // configured sidecar there's nowhere to upload bundles to.
  14. //
  15. // Backend wiring: backend/app/api/routes/slicer_presets.py exposes
  16. // /api/v1/slicer/bundles (POST/GET/DELETE). The list call returns []
  17. // when no sidecar is configured, so an empty render is the natural
  18. // "first-run" state for users who haven't enabled the sidecar yet.
  19. export function SlicerBundlesPanel() {
  20. const { t } = useTranslation();
  21. const queryClient = useQueryClient();
  22. const { showToast } = useToast();
  23. const fileInputRef = useRef<HTMLInputElement>(null);
  24. const [pendingDelete, setPendingDelete] = useState<SlicerBundle | null>(null);
  25. const { data: bundles, isLoading } = useQuery({
  26. queryKey: ['slicer-bundles'],
  27. queryFn: api.listSlicerBundles,
  28. });
  29. const importMutation = useMutation({
  30. mutationFn: (file: File) => api.importSlicerBundle(file),
  31. onSuccess: (bundle) => {
  32. queryClient.invalidateQueries({ queryKey: ['slicer-bundles'] });
  33. showToast(
  34. t('settings.slicerBundles.uploadSuccess', {
  35. defaultValue: 'Imported {{name}}',
  36. name: bundle.printer_preset_name,
  37. }),
  38. 'success',
  39. );
  40. // Reset the file input so the same file can be re-selected after a
  41. // failed retry. (Without this, a second click on the same file
  42. // doesn't trigger onChange and looks like the panel is broken.)
  43. if (fileInputRef.current) fileInputRef.current.value = '';
  44. },
  45. onError: (err: Error) => {
  46. showToast(
  47. t('settings.slicerBundles.uploadError', {
  48. defaultValue: 'Bundle upload failed: {{message}}',
  49. message: err.message,
  50. }),
  51. 'error',
  52. );
  53. if (fileInputRef.current) fileInputRef.current.value = '';
  54. },
  55. });
  56. const deleteMutation = useMutation({
  57. mutationFn: (bundleId: string) => api.deleteSlicerBundle(bundleId),
  58. onSuccess: () => {
  59. queryClient.invalidateQueries({ queryKey: ['slicer-bundles'] });
  60. setPendingDelete(null);
  61. showToast(
  62. t('settings.slicerBundles.deleteSuccess', {
  63. defaultValue: 'Bundle removed',
  64. }),
  65. 'success',
  66. );
  67. },
  68. onError: (err: Error) => {
  69. showToast(
  70. t('settings.slicerBundles.deleteError', {
  71. defaultValue: 'Bundle delete failed: {{message}}',
  72. message: err.message,
  73. }),
  74. 'error',
  75. );
  76. setPendingDelete(null);
  77. },
  78. });
  79. const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  80. const file = e.target.files?.[0];
  81. if (!file) return;
  82. importMutation.mutate(file);
  83. };
  84. return (
  85. <Card>
  86. <CardHeader>
  87. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  88. <Package className="w-4 h-4 text-bambu-green" />
  89. {t('settings.slicerBundles.title', { defaultValue: 'Slicer Bundles' })}
  90. </h3>
  91. </CardHeader>
  92. <CardContent className="space-y-3">
  93. <p className="text-xs text-bambu-gray">
  94. {t('settings.slicerBundles.description', {
  95. defaultValue:
  96. 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
  97. })}
  98. </p>
  99. <div className="flex items-center gap-2">
  100. <input
  101. ref={fileInputRef}
  102. type="file"
  103. accept=".bbscfg,.zip,application/zip"
  104. onChange={handleFileChange}
  105. className="hidden"
  106. disabled={importMutation.isPending}
  107. />
  108. <Button
  109. variant="primary"
  110. onClick={() => fileInputRef.current?.click()}
  111. disabled={importMutation.isPending}
  112. >
  113. {importMutation.isPending ? (
  114. <>
  115. <Loader2 className="w-4 h-4 animate-spin" />
  116. {t('settings.slicerBundles.uploading', { defaultValue: 'Uploading…' })}
  117. </>
  118. ) : (
  119. <>
  120. <Upload className="w-4 h-4" />
  121. {t('settings.slicerBundles.uploadButton', { defaultValue: 'Upload bundle' })}
  122. </>
  123. )}
  124. </Button>
  125. </div>
  126. {isLoading ? (
  127. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  128. <Loader2 className="w-4 h-4 animate-spin" />
  129. {t('settings.slicerBundles.loading', { defaultValue: 'Loading bundles…' })}
  130. </div>
  131. ) : bundles && bundles.length > 0 ? (
  132. <ul className="divide-y divide-bambu-dark-tertiary border border-bambu-dark-tertiary rounded-lg">
  133. {bundles.map((b) => (
  134. <li
  135. key={b.id}
  136. className="flex items-center justify-between px-3 py-2 hover:bg-bambu-dark-tertiary/30"
  137. >
  138. <div className="min-w-0 flex-1">
  139. <p className="text-sm text-white truncate">{b.printer_preset_name}</p>
  140. <p className="text-xs text-bambu-gray mt-0.5">
  141. {t('settings.slicerBundles.summary', {
  142. defaultValue:
  143. '{{processCount}} process · {{filamentCount}} filament presets',
  144. processCount: b.process.length,
  145. filamentCount: b.filament.length,
  146. })}
  147. {b.version && ` · v${b.version}`}
  148. </p>
  149. </div>
  150. <button
  151. type="button"
  152. onClick={() => setPendingDelete(b)}
  153. disabled={deleteMutation.isPending}
  154. className="ml-3 p-1.5 text-bambu-gray hover:text-red-400 disabled:opacity-40"
  155. aria-label={t('settings.slicerBundles.delete', { defaultValue: 'Delete' })}
  156. >
  157. <Trash2 className="w-4 h-4" />
  158. </button>
  159. </li>
  160. ))}
  161. </ul>
  162. ) : (
  163. <p className="text-sm text-bambu-gray italic">
  164. {t('settings.slicerBundles.empty', {
  165. defaultValue: 'No bundles imported yet.',
  166. })}
  167. </p>
  168. )}
  169. </CardContent>
  170. {pendingDelete && (
  171. <ConfirmModal
  172. title={t('settings.slicerBundles.confirmDeleteTitle', {
  173. defaultValue: 'Remove this bundle?',
  174. })}
  175. message={t('settings.slicerBundles.confirmDeleteMessage', {
  176. defaultValue:
  177. 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
  178. name: pendingDelete.printer_preset_name,
  179. })}
  180. confirmText={t('common.delete', { defaultValue: 'Delete' })}
  181. variant="danger"
  182. isLoading={deleteMutation.isPending}
  183. onConfirm={() => deleteMutation.mutate(pendingDelete.id)}
  184. onCancel={() => setPendingDelete(null)}
  185. />
  186. )}
  187. </Card>
  188. );
  189. }