CompactHistoryRow.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import {
  2. CheckCircle,
  3. XCircle,
  4. SkipForward,
  5. Ban,
  6. RefreshCw,
  7. Trash2,
  8. Printer,
  9. Timer,
  10. Layers,
  11. User,
  12. AlertCircle,
  13. Weight,
  14. } from 'lucide-react';
  15. import { queueItemThumbnailUrl } from '../utils/queueItemThumbnail';
  16. import { ThumbnailHoverPreview } from './ThumbnailHoverPreview';
  17. import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
  18. import type { PrintQueueItem, Permission } from '../api/client';
  19. import { Button } from './Button';
  20. import { ColorSubstitutionNotes } from './ColorSubstitutionNotes';
  21. import { queueItemDisplayName } from '../utils/queueItemName';
  22. const STATUS_CONFIG = {
  23. completed: { icon: CheckCircle, color: 'text-emerald-600 dark:text-emerald-400', border: 'border-l-emerald-500' },
  24. failed: { icon: XCircle, color: 'text-red-600 dark:text-red-400', border: 'border-l-red-500' },
  25. skipped: { icon: SkipForward, color: 'text-orange-600 dark:text-orange-400', border: 'border-l-gray-500' },
  26. cancelled: { icon: Ban, color: 'text-gray-400', border: 'border-l-gray-500' },
  27. } as const;
  28. /** Bambu encodes "no filament" as transparent/zeroed RGBA. The slicer's
  29. * filament_color is a hex string like "RRGGBBAA" or "RRGGBB"; treat all-zero
  30. * / unparsable as no swatch. */
  31. function normalizeFilamentColor(raw: string | null | undefined): string | null {
  32. if (!raw) return null;
  33. const clean = raw.startsWith('#') ? raw.slice(1) : raw;
  34. if (/^0{6,8}$/.test(clean)) return null;
  35. if (!/^[0-9a-fA-F]{6,8}$/.test(clean)) return null;
  36. return `#${clean.slice(0, 6)}`;
  37. }
  38. export function CompactHistoryRow({
  39. item,
  40. onRequeue,
  41. onRemove,
  42. timeFormat = 'system',
  43. hasPermission,
  44. canModify,
  45. t,
  46. }: {
  47. item: PrintQueueItem;
  48. onRequeue: () => void;
  49. onRemove: () => void;
  50. timeFormat?: TimeFormat;
  51. hasPermission: (permission: Permission) => boolean;
  52. canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
  53. t: (key: string, options?: Record<string, unknown>) => string;
  54. }) {
  55. const config = STATUS_CONFIG[item.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.cancelled;
  56. const StatusIcon = config.icon;
  57. const displayName = queueItemDisplayName(item);
  58. const thumbnailUrl = queueItemThumbnailUrl(item);
  59. const completedTime = item.completed_at || item.created_at;
  60. const filamentColor = normalizeFilamentColor(item.filament_color);
  61. const filamentMass = item.filament_used_grams ? Math.round(item.filament_used_grams) : null;
  62. // Failed and skipped prints carry the diagnostic in error_message; surface
  63. // it inline so the user doesn't have to reopen the row to see why.
  64. const showErrorMessage = !!item.error_message
  65. && (item.status === 'failed' || item.status === 'skipped');
  66. return (
  67. <div className={`px-3 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary border-l-[3px] ${config.border}`}>
  68. {/* Top row — status / thumb / name / time / actions */}
  69. <div className="flex items-center gap-2 sm:gap-3">
  70. <StatusIcon className={`w-4 h-4 shrink-0 ${config.color}`} />
  71. <ThumbnailHoverPreview src={thumbnailUrl} className="w-8 h-8 shrink-0 bg-bambu-dark rounded overflow-hidden">
  72. {thumbnailUrl ? (
  73. <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
  74. ) : (
  75. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  76. <Layers className="w-4 h-4" />
  77. </div>
  78. )}
  79. </ThumbnailHoverPreview>
  80. <span className="text-sm text-white font-medium truncate min-w-0 flex-1">
  81. {displayName}
  82. </span>
  83. <span
  84. className="text-xs text-bambu-gray shrink-0"
  85. title={completedTime ?? undefined}
  86. >
  87. {formatRelativeTime(completedTime, timeFormat, t)}
  88. </span>
  89. <div className="flex items-center gap-0.5 shrink-0">
  90. <Button
  91. variant="ghost"
  92. size="sm"
  93. onClick={onRequeue}
  94. disabled={!hasPermission('queue:create')}
  95. title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
  96. className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5"
  97. >
  98. <RefreshCw className="w-3.5 h-3.5" />
  99. </Button>
  100. <Button
  101. variant="ghost"
  102. size="sm"
  103. onClick={onRemove}
  104. disabled={!canModify('queue', 'delete', item.created_by_id)}
  105. title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
  106. className="p-1.5"
  107. >
  108. <Trash2 className="w-3.5 h-3.5" />
  109. </Button>
  110. </div>
  111. </div>
  112. {/* Meta row — printer / filament / duration / user. Indented under the
  113. thumbnail so it lines up with the name. */}
  114. <div className="mt-1 ml-[3.25rem] flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-bambu-gray">
  115. {item.printer_name && (
  116. <span className="flex items-center gap-1 shrink-0">
  117. <Printer className="w-3 h-3" />
  118. <span className="truncate max-w-[100px] sm:max-w-[140px]">{item.printer_name}</span>
  119. </span>
  120. )}
  121. {(filamentColor || filamentMass) && (
  122. <span className="flex items-center gap-1 shrink-0">
  123. {filamentColor ? (
  124. <span
  125. className="inline-block w-2.5 h-2.5 rounded-full border border-white/15"
  126. style={{ backgroundColor: filamentColor }}
  127. aria-hidden
  128. />
  129. ) : (
  130. <Weight className="w-3 h-3" />
  131. )}
  132. <span className="truncate">
  133. {filamentMass ? `${filamentMass}g` : null}
  134. {filamentMass && item.filament_type ? ` ${item.filament_type}` : null}
  135. {!filamentMass && item.filament_type ? item.filament_type : null}
  136. </span>
  137. </span>
  138. )}
  139. {item.print_time_seconds && (
  140. <span className="flex items-center gap-1 shrink-0">
  141. <Timer className="w-3 h-3" />
  142. {formatDuration(item.print_time_seconds)}
  143. </span>
  144. )}
  145. {item.created_by_username && (
  146. <span
  147. className="flex items-center gap-1 shrink-0"
  148. title={t('queue.addedBy', { name: item.created_by_username })}
  149. >
  150. <User className="w-3 h-3" />
  151. <span className="truncate max-w-[120px]">{item.created_by_username}</span>
  152. </span>
  153. )}
  154. </div>
  155. {/* Printed with a spool of another colour than the file asks for (#3279). */}
  156. <ColorSubstitutionNotes substitutions={item.color_substitutions} t={t} className="mt-1 ml-[3.25rem] text-xs" />
  157. {/* Error message — only rendered on failed/skipped rows. */}
  158. {showErrorMessage && (
  159. <div className="mt-1 ml-[3.25rem] flex items-start gap-1 text-xs text-red-700 dark:text-red-400">
  160. <AlertCircle className="w-3 h-3 mt-0.5 shrink-0" />
  161. <span className="break-words">{item.error_message}</span>
  162. </div>
  163. )}
  164. </div>
  165. );
  166. }