Added screenshots

This commit is contained in:
Martin Ziegler
2025-11-28 10:23:59 +01:00
parent 8a85147713
commit 09677861ba
94 changed files with 25918 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+51
View File
@@ -0,0 +1,51 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Layout } from './components/Layout';
import { PrintersPage } from './pages/PrintersPage';
import { ArchivesPage } from './pages/ArchivesPage';
import { StatsPage } from './pages/StatsPage';
import { SettingsPage } from './pages/SettingsPage';
import { CloudProfilesPage } from './pages/CloudProfilesPage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
retry: 1,
},
},
});
function WebSocketProvider({ children }: { children: React.ReactNode }) {
useWebSocket();
return <>{children}</>;
}
function App() {
return (
<ThemeProvider>
<ToastProvider>
<QueryClientProvider client={queryClient}>
<WebSocketProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<PrintersPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="cloud" element={<CloudProfilesPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
</WebSocketProvider>
</QueryClientProvider>
</ToastProvider>
</ThemeProvider>
);
}
export default App;
+363
View File
@@ -0,0 +1,363 @@
const API_BASE = '/api/v1';
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// Printer types
export interface Printer {
id: number;
name: string;
serial_number: string;
ip_address: string;
access_code: string;
model: string | null;
is_active: boolean;
auto_archive: boolean;
created_at: string;
updated_at: string;
}
export interface PrinterStatus {
id: number;
name: string;
connected: boolean;
state: string | null;
current_print: string | null;
subtask_name: string | null;
gcode_file: string | null;
progress: number | null;
remaining_time: number | null;
layer_num: number | null;
total_layers: number | null;
temperatures: {
bed?: number;
bed_target?: number;
nozzle?: number;
nozzle_target?: number;
chamber?: number;
} | null;
cover_url: string | null;
}
export interface PrinterCreate {
name: string;
serial_number: string;
ip_address: string;
access_code: string;
model?: string;
auto_archive?: boolean;
}
// Archive types
export interface Archive {
id: number;
printer_id: number | null;
filename: string;
file_path: string;
file_size: number;
thumbnail_path: string | null;
timelapse_path: string | null;
print_name: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filament_type: string | null;
filament_color: string | null;
layer_height: number | null;
nozzle_diameter: number | null;
bed_temperature: number | null;
nozzle_temperature: number | null;
status: string;
started_at: string | null;
completed_at: string | null;
extra_data: Record<string, unknown> | null;
makerworld_url: string | null;
designer: string | null;
is_favorite: boolean;
tags: string | null;
notes: string | null;
cost: number | null;
photos: string[] | null;
failure_reason: string | null;
created_at: string;
}
export interface ArchiveStats {
total_prints: number;
successful_prints: number;
failed_prints: number;
total_print_time_hours: number;
total_filament_grams: number;
total_cost: number;
prints_by_filament_type: Record<string, number>;
prints_by_printer: Record<string, number>;
}
export interface BulkUploadResult {
uploaded: number;
failed: number;
results: Array<{ filename: string; id: number; status: string }>;
errors: Array<{ filename: string; error: string }>;
}
// Settings types
export interface AppSettings {
auto_archive: boolean;
save_thumbnails: boolean;
default_filament_cost: number;
currency: string;
}
export type AppSettingsUpdate = Partial<AppSettings>;
// Cloud types
export interface CloudAuthStatus {
is_authenticated: boolean;
email: string | null;
}
export interface CloudLoginResponse {
success: boolean;
needs_verification: boolean;
message: string;
}
export interface SlicerSetting {
setting_id: string;
name: string;
type: string;
version: string | null;
user_id: string | null;
updated_time: string | null;
}
export interface SlicerSettingsResponse {
filament: SlicerSetting[];
printer: SlicerSetting[];
process: SlicerSetting[];
}
export interface CloudDevice {
dev_id: string;
name: string;
dev_model_name: string | null;
dev_product_name: string | null;
online: boolean;
}
// API functions
export const api = {
// Printers
getPrinters: () => request<Printer[]>('/printers/'),
getPrinter: (id: number) => request<Printer>(`/printers/${id}`),
createPrinter: (data: PrinterCreate) =>
request<Printer>('/printers/', {
method: 'POST',
body: JSON.stringify(data),
}),
updatePrinter: (id: number, data: Partial<PrinterCreate>) =>
request<Printer>(`/printers/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deletePrinter: (id: number) =>
request<void>(`/printers/${id}`, { method: 'DELETE' }),
getPrinterStatus: (id: number) =>
request<PrinterStatus>(`/printers/${id}/status`),
connectPrinter: (id: number) =>
request<{ connected: boolean }>(`/printers/${id}/connect`, {
method: 'POST',
}),
disconnectPrinter: (id: number) =>
request<{ connected: boolean }>(`/printers/${id}/disconnect`, {
method: 'POST',
}),
// Printer File Manager
getPrinterFiles: (printerId: number, path = '/') =>
request<{
path: string;
files: Array<{
name: string;
is_directory: boolean;
size: number;
path: string;
}>;
}>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
getPrinterFileDownloadUrl: (printerId: number, path: string) =>
`${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
deletePrinterFile: (printerId: number, path: string) =>
request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
}),
getPrinterStorage: (printerId: number) =>
request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
// Archives
getArchives: (printerId?: number, limit = 50, offset = 0) => {
const params = new URLSearchParams();
if (printerId) params.set('printer_id', String(printerId));
params.set('limit', String(limit));
params.set('offset', String(offset));
return request<Archive[]>(`/archives/?${params}`);
},
getArchive: (id: number) => request<Archive>(`/archives/${id}`),
updateArchive: (id: number, data: {
printer_id?: number | null;
print_name?: string;
is_favorite?: boolean;
tags?: string;
notes?: string;
cost?: number;
failure_reason?: string | null;
}) =>
request<Archive>(`/archives/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
toggleFavorite: (id: number) =>
request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
deleteArchive: (id: number) =>
request<void>(`/archives/${id}`, { method: 'DELETE' }),
getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse`,
scanArchiveTimelapse: (id: number) =>
request<{ status: string; message: string; filename?: string }>(`/archives/${id}/timelapse/scan`, {
method: 'POST',
}),
uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
// Photos
getArchivePhotoUrl: (archiveId: number, filename: string) =>
`${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,
uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
deleteArchivePhoto: (archiveId: number, filename: string) =>
request<{ status: string; photos: string[] | null }>(`/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, {
method: 'DELETE',
}),
// QR Code
getArchiveQRCodeUrl: (archiveId: number, size = 200) =>
`${API_BASE}/archives/${archiveId}/qrcode?size=${size}`,
getArchiveCapabilities: (id: number) =>
request<{
has_model: boolean;
has_gcode: boolean;
build_volume: { x: number; y: number; z: number };
}>(`/archives/${id}/capabilities`),
getArchiveForSlicer: (id: number, filename: string) =>
`${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
reprintArchive: (archiveId: number, printerId: number) =>
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
`/archives/${archiveId}/reprint?printer_id=${printerId}`,
{ method: 'POST' }
),
uploadArchive: async (file: File, printerId?: number): Promise<Archive> => {
const formData = new FormData();
formData.append('file', file);
const url = printerId
? `${API_BASE}/archives/upload?printer_id=${printerId}`
: `${API_BASE}/archives/upload`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
uploadArchivesBulk: async (files: File[], printerId?: number): Promise<BulkUploadResult> => {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
const url = printerId
? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`
: `${API_BASE}/archives/upload-bulk`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
// Settings
getSettings: () => request<AppSettings>('/settings/'),
updateSettings: (data: AppSettingsUpdate) =>
request<AppSettings>('/settings/', {
method: 'PUT',
body: JSON.stringify(data),
}),
resetSettings: () =>
request<AppSettings>('/settings/reset', { method: 'POST' }),
// Cloud
getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
cloudLogin: (email: string, password: string, region = 'global') =>
request<CloudLoginResponse>('/cloud/login', {
method: 'POST',
body: JSON.stringify({ email, password, region }),
}),
cloudVerify: (email: string, code: string) =>
request<CloudLoginResponse>('/cloud/verify', {
method: 'POST',
body: JSON.stringify({ email, code }),
}),
cloudSetToken: (access_token: string) =>
request<CloudAuthStatus>('/cloud/token', {
method: 'POST',
body: JSON.stringify({ access_token }),
}),
cloudLogout: () =>
request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
getCloudSettings: (version = '01.09.00.00') =>
request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
getCloudSettingDetail: (settingId: string) =>
request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+215
View File
@@ -0,0 +1,215 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Tag, Plus, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface BatchTagModalProps {
selectedIds: number[];
existingTags: string[];
onClose: () => void;
}
export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [newTag, setNewTag] = useState('');
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [mode, setMode] = useState<'add' | 'remove'>('add');
const batchTagMutation = useMutation({
mutationFn: async () => {
const tagsArray = Array.from(selectedTags);
await Promise.all(
selectedIds.map(async (id) => {
const archive = await api.getArchive(id);
const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
let newTags: string[];
if (mode === 'add') {
// Add tags that aren't already present
newTags = [...new Set([...currentTags, ...tagsArray])];
} else {
// Remove selected tags
newTags = currentTags.filter(t => !selectedTags.has(t));
}
return api.updateArchive(id, { tags: newTags.join(', ') });
})
);
return { count: selectedIds.length, mode, tags: tagsArray };
},
onSuccess: ({ count, mode, tags }) => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
onClose();
},
onError: () => {
showToast('Failed to update tags', 'error');
},
});
const toggleTag = (tag: string) => {
setSelectedTags((prev) => {
const next = new Set(prev);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
return next;
});
};
const addNewTag = () => {
if (newTag.trim() && !selectedTags.has(newTag.trim())) {
setSelectedTags((prev) => new Set([...prev, newTag.trim()]));
setNewTag('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag();
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">
{mode === 'add' ? 'Add Tags' : 'Remove Tags'}
</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
<p className="text-sm text-bambu-gray">
{mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''}
</p>
{/* Mode toggle */}
<div className="flex gap-2">
<Button
size="sm"
variant={mode === 'add' ? 'primary' : 'secondary'}
onClick={() => setMode('add')}
>
Add Tags
</Button>
<Button
size="sm"
variant={mode === 'remove' ? 'primary' : 'secondary'}
onClick={() => setMode('remove')}
>
Remove Tags
</Button>
</div>
{/* New tag input (only for add mode) */}
{mode === 'add' && (
<div className="flex gap-2">
<input
type="text"
placeholder="Enter new tag..."
className="flex-1 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"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button size="sm" variant="secondary" onClick={addNewTag} disabled={!newTag.trim()}>
<Plus className="w-4 h-4" />
</Button>
</div>
)}
{/* Existing tags */}
{existingTags.length > 0 && (
<div>
<p className="text-xs text-bambu-gray mb-2">Existing tags:</p>
<div className="flex flex-wrap gap-2">
{existingTags.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-2 py-1 rounded text-sm transition-colors ${
selectedTags.has(tag)
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray-light hover:bg-bambu-dark'
}`}
>
{tag}
</button>
))}
</div>
</div>
)}
{/* Selected tags preview */}
{selectedTags.size > 0 && (
<div>
<p className="text-xs text-bambu-gray mb-2">
Tags to {mode === 'add' ? 'add' : 'remove'}:
</p>
<div className="flex flex-wrap gap-2">
{Array.from(selectedTags).map((tag) => (
<span
key={tag}
className={`px-2 py-1 rounded text-sm flex items-center gap-1 ${
mode === 'add' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}
>
{tag}
<button onClick={() => toggleTag(tag)} className="hover:opacity-70">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
onClick={() => batchTagMutation.mutate()}
disabled={selectedTags.size === 0 || batchTagMutation.isPending}
className="flex-1"
>
{batchTagMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : (
<>
<Tag className="w-4 h-4" />
{mode === 'add' ? 'Add Tags' : 'Remove Tags'}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}: ButtonProps) {
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green',
secondary:
'bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
ghost:
'bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-6 py-3 text-base gap-2',
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { useState, useMemo } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { Archive } from '../api/client';
import { api } from '../api/client';
interface CalendarViewProps {
archives: Archive[];
onArchiveClick?: (archive: Archive) => void;
}
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
const today = new Date();
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// Group archives by date
const archivesByDate = useMemo(() => {
const map = new Map<string, Archive[]>();
archives.forEach(archive => {
const date = new Date(archive.completed_at || archive.created_at);
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const existing = map.get(key) || [];
existing.push(archive);
map.set(key, existing);
});
return map;
}, [archives]);
const daysInMonth = getDaysInMonth(currentYear, currentMonth);
const firstDay = getFirstDayOfMonth(currentYear, currentMonth);
const prevMonth = () => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
};
const nextMonth = () => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
};
const goToToday = () => {
setCurrentMonth(today.getMonth());
setCurrentYear(today.getFullYear());
};
// Build calendar grid
const calendarDays: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) {
calendarDays.push(null);
}
for (let day = 1; day <= daysInMonth; day++) {
calendarDays.push(day);
}
const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
return (
<div className="flex flex-col lg:flex-row gap-6">
{/* Calendar */}
<div className="flex-1">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<button
onClick={prevMonth}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5 text-bambu-gray" />
</button>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-white">
{MONTH_NAMES[currentMonth]} {currentYear}
</h2>
<button
onClick={goToToday}
className="px-2 py-1 text-xs bg-bambu-dark-tertiary hover:bg-bambu-green/20 text-bambu-gray hover:text-white rounded transition-colors"
>
Today
</button>
</div>
<button
onClick={nextMonth}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
>
<ChevronRight className="w-5 h-5 text-bambu-gray" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{DAY_NAMES.map(day => (
<div key={day} className="text-center text-xs text-bambu-gray py-2">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="aspect-square" />;
}
const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dayArchives = archivesByDate.get(dateKey) || [];
const hasArchives = dayArchives.length > 0;
const isToday = day === today.getDate() && currentMonth === today.getMonth() && currentYear === today.getFullYear();
const isSelected = dateKey === selectedDate;
const successCount = dayArchives.filter(a => a.status === 'completed').length;
const failedCount = dayArchives.filter(a => a.status === 'failed').length;
return (
<button
key={day}
onClick={() => setSelectedDate(isSelected ? null : dateKey)}
className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${
isSelected
? 'bg-bambu-green text-white'
: isToday
? 'bg-bambu-green/20 text-white ring-2 ring-bambu-green'
: hasArchives
? 'bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 text-white'
: 'hover:bg-bambu-dark-tertiary/50 text-bambu-gray'
}`}
>
<span className={`text-sm font-medium ${isToday && !isSelected ? 'text-bambu-green' : ''}`}>
{day}
</span>
{hasArchives && (
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex items-center gap-1">
<div className={`w-2 h-2 rounded-full ${
failedCount > 0 && successCount === 0
? 'bg-red-400'
: failedCount > 0
? 'bg-yellow-400'
: 'bg-green-400'
}`} />
<span className="text-xs font-medium">{dayArchives.length}</span>
</div>
)}
</button>
);
})}
</div>
{/* Monthly stats */}
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-white">
{archives.filter(a => {
const d = new Date(a.completed_at || a.created_at);
return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
}).length}
</div>
<div className="text-xs text-bambu-gray">Prints this month</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">
{archives.filter(a => {
const d = new Date(a.completed_at || a.created_at);
return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed';
}).length}
</div>
<div className="text-xs text-bambu-gray">Successful</div>
</div>
<div>
<div className="text-2xl font-bold text-red-400">
{archives.filter(a => {
const d = new Date(a.completed_at || a.created_at);
return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed';
}).length}
</div>
<div className="text-xs text-bambu-gray">Failed</div>
</div>
</div>
</div>
</div>
{/* Selected day details */}
<div className="lg:w-80 bg-bambu-dark rounded-xl p-4">
{selectedDate ? (
<>
<h3 className="text-sm font-medium text-bambu-gray mb-3">
{new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</h3>
{selectedArchives.length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto">
{selectedArchives.map(archive => (
<button
key={archive.id}
onClick={() => onArchiveClick?.(archive)}
className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-left"
>
{archive.thumbnail_path ? (
<img
src={api.getArchiveThumbnail(archive.id)}
alt=""
className="w-12 h-12 rounded object-cover"
/>
) : (
<div className="w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<span className="text-xs text-bambu-gray">3MF</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">
{archive.print_name || archive.filename}
</p>
<div className="flex items-center gap-2 text-xs">
<span className={archive.status === 'failed' ? 'text-red-400' : 'text-green-400'}>
{archive.status === 'failed' ? 'Failed' : 'Completed'}
</span>
{archive.filament_color && (
<div className="flex gap-0.5">
{archive.filament_color.split(',').map((color, i) => (
<div
key={i}
className="w-3 h-3 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
))}
</div>
)}
</div>
</div>
</button>
))}
</div>
) : (
<p className="text-sm text-bambu-gray">No prints on this day</p>
)}
</>
) : (
<div className="text-center py-8">
<p className="text-sm text-bambu-gray">Select a day to see prints</p>
</div>
)}
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode, MouseEvent } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
onClick?: (e: MouseEvent) => void;
onContextMenu?: (e: MouseEvent) => void;
}
export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
return (
<div
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary ${className}`}
onClick={onClick}
onContextMenu={onContextMenu}
>
{children}
</div>
);
}
export function CardHeader({ children, className = '' }: CardProps) {
return (
<div className={`px-6 py-4 border-b border-bambu-dark-tertiary ${className}`}>
{children}
</div>
);
}
export function CardContent({ children, className = '' }: CardProps) {
return <div className={`p-6 ${className}`}>{children}</div>;
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
interface ConfirmModalProps {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'default';
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmModal({
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'default',
onConfirm,
onCancel,
}: ConfirmModalProps) {
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCancel]);
const variantStyles = {
danger: {
icon: 'text-red-400',
button: 'bg-red-500 hover:bg-red-600',
},
warning: {
icon: 'text-yellow-400',
button: 'bg-yellow-500 hover:bg-yellow-600',
},
default: {
icon: 'text-bambu-green',
button: 'bg-bambu-green hover:bg-bambu-green-dark',
},
};
const styles = variantStyles[variant];
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onCancel}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
<AlertTriangle className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-bambu-gray text-sm">{message}</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<Button variant="secondary" onClick={onCancel} className="flex-1">
{cancelText}
</Button>
<Button
onClick={onConfirm}
className={`flex-1 ${styles.button}`}
>
{confirmText}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef } from 'react';
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
const handleScroll = () => {
onClose();
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('scroll', handleScroll, true);
};
}, [onClose]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + rect.width > viewportWidth) {
adjustedX = viewportWidth - rect.width - 8;
}
if (y + rect.height > viewportHeight) {
adjustedY = viewportHeight - rect.height - 8;
}
menuRef.current.style.left = `${adjustedX}px`;
menuRef.current.style.top = `${adjustedY}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-50 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden"
style={{ left: x, top: y }}
>
{items.map((item, index) => {
if (item.divider) {
return <div key={index} className="my-1 border-t border-bambu-dark-tertiary" />;
}
return (
<button
key={index}
onClick={() => {
if (!item.disabled) {
item.onClick();
onClose();
}
}}
disabled={item.disabled}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
item.disabled
? 'text-bambu-gray cursor-not-allowed'
: item.danger
? 'text-red-400 hover:bg-red-400/10'
: 'text-white hover:bg-bambu-dark-tertiary'
}`}
>
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
{item.label}
</button>
);
})}
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useState, useEffect, type ReactNode } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Eye, EyeOff, RotateCcw, Maximize2, Minimize2 } from 'lucide-react';
import { Button } from './Button';
export interface DashboardWidget {
id: string;
title: string;
component: ReactNode;
defaultVisible?: boolean;
defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default)
}
interface DashboardProps {
widgets: DashboardWidget[];
storageKey: string;
columns?: number;
}
interface LayoutState {
order: string[];
hidden: string[];
sizes: Record<string, 1 | 2 | 4>;
}
function SortableWidget({
id,
title,
children,
isHidden,
size,
onToggleVisibility,
onToggleSize,
}: {
id: string;
title: string;
children: ReactNode;
isHidden: boolean;
size: 1 | 2 | 4;
onToggleVisibility: () => void;
onToggleSize: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
if (isHidden) return null;
return (
<div
ref={setNodeRef}
style={{
...style,
gridColumn: `span ${size}`,
}}
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden ${
isDragging ? 'ring-2 ring-bambu-green shadow-lg' : ''
}`}
>
{/* Widget Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/30">
<div className="flex items-center gap-2">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
</button>
<h3 className="text-sm font-medium text-white">{title}</h3>
</div>
<div className="flex items-center gap-1">
<button
onClick={onToggleSize}
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
title={`Size: ${size === 1 ? '1/4' : size === 2 ? '1/2' : 'Full'} - Click to cycle`}
>
{size === 4 ? (
<Minimize2 className="w-4 h-4 text-bambu-gray hover:text-white" />
) : (
<Maximize2 className="w-4 h-4 text-bambu-gray hover:text-white" />
)}
</button>
<button
onClick={onToggleVisibility}
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
title="Hide widget"
>
<EyeOff className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
</div>
</div>
{/* Widget Content */}
<div className="p-4">{children}</div>
</div>
);
}
export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) {
// Build default sizes from widget definitions
const getDefaultSizes = () => {
const sizes: Record<string, 1 | 2 | 4> = {};
widgets.forEach((w) => {
sizes[w.id] = w.defaultSize || 4;
});
return sizes;
};
const [layout, setLayout] = useState<LayoutState>(() => {
// Load saved layout from localStorage
const saved = localStorage.getItem(storageKey);
if (saved) {
try {
const parsed = JSON.parse(saved);
// Ensure sizes exist (for backwards compatibility)
if (!parsed.sizes) {
parsed.sizes = getDefaultSizes();
}
return parsed;
} catch {
// Invalid JSON, use default
}
}
// Default layout: all widgets visible in original order
return {
order: widgets.map((w) => w.id),
hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
sizes: getDefaultSizes(),
};
});
const [showHiddenPanel, setShowHiddenPanel] = useState(false);
// Save layout to localStorage whenever it changes
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(layout));
}, [layout, storageKey]);
// Ensure all widget IDs are in the order array (for newly added widgets)
useEffect(() => {
const allIds = widgets.map((w) => w.id);
const missingIds = allIds.filter((id) => !layout.order.includes(id));
if (missingIds.length > 0) {
setLayout((prev) => ({
...prev,
order: [...prev.order, ...missingIds],
}));
}
}, [widgets, layout.order]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setLayout((prev) => {
const oldIndex = prev.order.indexOf(active.id as string);
const newIndex = prev.order.indexOf(over.id as string);
return {
...prev,
order: arrayMove(prev.order, oldIndex, newIndex),
};
});
}
};
const toggleVisibility = (id: string) => {
setLayout((prev) => ({
...prev,
hidden: prev.hidden.includes(id)
? prev.hidden.filter((h) => h !== id)
: [...prev.hidden, id],
}));
};
const toggleSize = (id: string) => {
setLayout((prev) => {
const currentSize = prev.sizes[id] || 4;
// Cycle: 1 → 2 → 4 → 1
const nextSize = currentSize === 1 ? 2 : currentSize === 2 ? 4 : 1;
return {
...prev,
sizes: {
...prev.sizes,
[id]: nextSize as 1 | 2 | 4,
},
};
});
};
const resetLayout = () => {
const defaultLayout = {
order: widgets.map((w) => w.id),
hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
sizes: getDefaultSizes(),
};
setLayout(defaultLayout);
};
// Get ordered widgets
const orderedWidgets = layout.order
.map((id) => widgets.find((w) => w.id === id))
.filter(Boolean) as DashboardWidget[];
const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id));
const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id));
return (
<div className="space-y-4">
{/* Dashboard Controls */}
<div className="flex items-center justify-end gap-2">
{hiddenWidgets.length > 0 && (
<Button
variant="secondary"
size="sm"
onClick={() => setShowHiddenPanel(!showHiddenPanel)}
>
<Eye className="w-4 h-4" />
{hiddenWidgets.length} Hidden
</Button>
)}
<Button variant="secondary" size="sm" onClick={resetLayout}>
<RotateCcw className="w-4 h-4" />
Reset Layout
</Button>
</div>
{/* Hidden Widgets Panel */}
{showHiddenPanel && hiddenWidgets.length > 0 && (
<div className="p-4 bg-bambu-dark rounded-xl border border-bambu-dark-tertiary">
<p className="text-sm text-bambu-gray mb-3">Hidden widgets (click to show):</p>
<div className="flex flex-wrap gap-2">
{hiddenWidgets.map((widget) => (
<button
key={widget.id}
onClick={() => toggleVisibility(widget.id)}
className="px-3 py-1.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded-lg text-sm text-white transition-colors flex items-center gap-2"
>
<Eye className="w-3 h-3" />
{widget.title}
</button>
))}
</div>
</div>
)}
{/* Draggable Widgets Grid */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={visibleWidgets.map((w) => w.id)} strategy={rectSortingStrategy}>
<div
className="grid gap-6"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{visibleWidgets.map((widget) => (
<SortableWidget
key={widget.id}
id={widget.id}
title={widget.title}
isHidden={layout.hidden.includes(widget.id)}
size={layout.sizes[widget.id] || 2}
onToggleVisibility={() => toggleVisibility(widget.id)}
onToggleSize={() => toggleSize(widget.id)}
>
{widget.component}
</SortableWidget>
))}
</div>
</SortableContext>
</DndContext>
{visibleWidgets.length === 0 && (
<div className="text-center py-12 text-bambu-gray">
<p>All widgets are hidden.</p>
<Button className="mt-4" onClick={resetLayout}>
Reset Layout
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,358 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Tag, Camera, Trash2, Loader2, Plus } from 'lucide-react';
import { api } from '../api/client';
import type { Archive } from '../api/client';
import { Button } from './Button';
const FAILURE_REASONS = [
'Adhesion failure',
'Spaghetti / Detached',
'Layer shift',
'Clogged nozzle',
'Filament runout',
'Warping',
'Stringing',
'Under-extrusion',
'Power failure',
'User cancelled',
'Other',
];
interface EditArchiveModalProps {
archive: Archive;
onClose: () => void;
existingTags?: string[];
}
export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const queryClient = useQueryClient();
const [printName, setPrintName] = useState(archive.print_name || '');
const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
const [notes, setNotes] = useState(archive.notes || '');
const [tags, setTags] = useState(archive.tags || '');
const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
const [photos, setPhotos] = useState<string[]>(archive.photos || []);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
const tagInputRef = useRef<HTMLInputElement>(null);
const photoInputRef = useRef<HTMLInputElement>(null);
const blurTimeoutRef = useRef<number | null>(null);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Get all archives to extract existing tags if not provided
const { data: archives } = useQuery({
queryKey: ['archives'],
queryFn: () => api.getArchives(undefined, 1000, 0),
enabled: existingTags.length === 0,
});
// Extract unique tags from all archives
const allTags = existingTags.length > 0
? existingTags
: [...new Set(
archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
)].sort();
// Get current tags as array
const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
// Filter suggestions based on what's not already added
const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
// Add a tag
const addTag = (tag: string) => {
if (!currentTags.includes(tag)) {
const newTags = [...currentTags, tag].join(', ');
setTags(newTags);
}
// Clear any pending blur timeout to prevent hiding suggestions
if (blurTimeoutRef.current !== null) {
clearTimeout(blurTimeoutRef.current);
}
tagInputRef.current?.focus();
};
// Remove a tag
const removeTag = (tagToRemove: string) => {
const newTags = currentTags.filter(t => t !== tagToRemove).join(', ');
setTags(newTags);
};
const updateMutation = useMutation({
mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
api.updateArchive(archive.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
onClose();
},
});
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingPhoto(true);
try {
const result = await api.uploadArchivePhoto(archive.id, file);
setPhotos(result.photos);
queryClient.invalidateQueries({ queryKey: ['archives'] });
} catch (error) {
console.error('Failed to upload photo:', error);
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) {
photoInputRef.current.value = '';
}
}
};
const handlePhotoDelete = async (filename: string) => {
try {
const result = await api.deleteArchivePhoto(archive.id, filename);
setPhotos(result.photos || []);
queryClient.invalidateQueries({ queryKey: ['archives'] });
} catch (error) {
console.error('Failed to delete photo:', error);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate({
print_name: printName || undefined,
printer_id: printerId,
notes: notes || undefined,
tags: tags || undefined,
failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined,
});
};
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">Edit Archive</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{/* Print Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Name</label>
<input
type="text"
value={printName}
onChange={(e) => setPrintName(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="Print name"
/>
</div>
{/* Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
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="">No printer</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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 resize-none"
placeholder="Add notes about this print..."
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
{/* Current tags as chips */}
{currentTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white"
>
<Tag className="w-3 h-3" />
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-bambu-gray hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Tag input with suggestions */}
<div className="relative">
<input
ref={tagInputRef}
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
onFocus={() => {
if (blurTimeoutRef.current !== null) {
clearTimeout(blurTimeoutRef.current);
}
setShowTagSuggestions(true);
}}
onBlur={() => {
blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
}}
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={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
/>
{/* Suggestions dropdown */}
{showTagSuggestions && tagSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
Existing tags (click to add)
</div>
<div className="p-2 flex flex-wrap gap-1.5">
{tagSuggestions.map((tag) => (
<button
key={tag}
type="button"
onClick={() => addTag(tag)}
className="px-2 py-0.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded text-sm text-bambu-gray hover:text-white transition-colors"
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Failure Reason - only show for failed prints */}
{archive.status === 'failed' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
<select
value={failureReason}
onChange={(e) => setFailureReason(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="">Select reason...</option>
{FAILURE_REASONS.map((reason) => (
<option key={reason} value={reason}>
{reason}
</option>
))}
</select>
</div>
)}
{/* Photos */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Camera className="w-4 h-4 inline mr-1" />
Photos of Printed Result
</label>
{/* Photo grid */}
<div className="flex flex-wrap gap-2 mb-2">
{photos.map((filename) => (
<div key={filename} className="relative group">
<img
src={api.getArchivePhotoUrl(archive.id, filename)}
alt="Print result"
className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
/>
<button
type="button"
onClick={() => handlePhotoDelete(filename)}
className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3 text-white" />
</button>
</div>
))}
{/* Upload button */}
<label className="w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors">
<input
ref={photoInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handlePhotoUpload}
className="hidden"
disabled={uploadingPhoto}
/>
{uploadingPhoto ? (
<Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
) : (
<Plus className="w-6 h-6 text-bambu-gray" />
)}
</label>
</div>
<p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={updateMutation.isPending}
className="flex-1"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</div>
</form>
</div>
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
import { useMemo, useState } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import type { Archive } from '../api/client';
interface FilamentTrendsProps {
archives: Archive[];
currency?: string;
}
type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
function getDateRange(range: TimeRange): Date {
const now = new Date();
switch (range) {
case '7d':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case '30d':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case '90d':
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
case '365d':
return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
case 'all':
return new Date(0);
}
}
export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
// Filter archives by time range
const filteredArchives = useMemo(() => {
const startDate = getDateRange(timeRange);
return archives.filter(a => new Date(a.completed_at || a.created_at) >= startDate);
}, [archives, timeRange]);
// Calculate daily usage data
const dailyData = useMemo(() => {
const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
filteredArchives.forEach(archive => {
const date = new Date(archive.completed_at || archive.created_at);
const key = date.toISOString().split('T')[0];
const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
existing.filament += archive.filament_used_grams || 0;
existing.cost += archive.cost || 0;
existing.prints += 1;
dataMap.set(key, existing);
});
return Array.from(dataMap.values())
.sort((a, b) => a.date.localeCompare(b.date))
.map(d => ({
...d,
dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
}));
}, [filteredArchives]);
// Calculate weekly aggregated data for longer time ranges
const weeklyData = useMemo(() => {
if (timeRange === '7d' || timeRange === '30d') return dailyData;
const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
filteredArchives.forEach(archive => {
const date = new Date(archive.completed_at || archive.created_at);
// Get week start (Sunday)
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
const key = weekStart.toISOString().split('T')[0];
const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
existing.filament += archive.filament_used_grams || 0;
existing.cost += archive.cost || 0;
existing.prints += 1;
dataMap.set(key, existing);
});
return Array.from(dataMap.values())
.sort((a, b) => a.week.localeCompare(b.week))
.map(d => ({
date: d.week,
dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
...d,
}));
}, [filteredArchives, dailyData, timeRange]);
// Usage by filament type
const filamentTypeData = useMemo(() => {
const dataMap = new Map<string, number>();
filteredArchives.forEach(archive => {
const type = archive.filament_type || 'Unknown';
// Handle multiple types (e.g., "PLA, PETG")
const types = type.split(', ');
types.forEach(t => {
const grams = (archive.filament_used_grams || 0) / types.length;
dataMap.set(t, (dataMap.get(t) || 0) + grams);
});
});
return Array.from(dataMap.entries())
.map(([name, value]) => ({ name, value: Math.round(value) }))
.sort((a, b) => b.value - a.value);
}, [filteredArchives]);
// Monthly comparison data
const monthlyComparison = useMemo(() => {
const now = new Date();
const months: { month: string; filament: number; cost: number; prints: number }[] = [];
for (let i = 5; i >= 0; i--) {
const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
const monthArchives = archives.filter(a => {
const d = new Date(a.completed_at || a.created_at);
return d >= monthDate && d <= monthEnd;
});
months.push({
month: monthStr,
filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
prints: monthArchives.length,
});
}
return months;
}, [archives]);
const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
return (
<div className="space-y-6">
{/* Time Range Selector */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
<div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
{(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
timeRange === range
? 'bg-bambu-green text-white'
: 'text-bambu-gray hover:text-white'
}`}
>
{range === 'all' ? 'All' : range.replace('d', 'D')}
</button>
))}
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-bambu-dark rounded-lg p-4">
<p className="text-sm text-bambu-gray">Period Filament</p>
<p className="text-2xl font-bold text-white">{(totalFilament / 1000).toFixed(2)}kg</p>
<p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
</div>
<div className="bg-bambu-dark rounded-lg p-4">
<p className="text-sm text-bambu-gray">Period Cost</p>
<p className="text-2xl font-bold text-white">{currency}{totalCost.toFixed(2)}</p>
<p className="text-xs text-bambu-gray">{filteredArchives.length} prints</p>
</div>
<div className="bg-bambu-dark rounded-lg p-4">
<p className="text-sm text-bambu-gray">Avg per Print</p>
<p className="text-2xl font-bold text-white">
{filteredArchives.length > 0
? (totalFilament / filteredArchives.length).toFixed(0)
: 0}g
</p>
<p className="text-xs text-bambu-gray">
{currency}{filteredArchives.length > 0 ? (totalCost / filteredArchives.length).toFixed(2) : '0.00'} avg
</p>
</div>
</div>
{/* Usage Over Time Chart */}
{chartData.length > 0 ? (
<div className="bg-bambu-dark rounded-lg p-4">
<h4 className="text-sm font-medium text-bambu-gray mb-4">Usage Over Time</h4>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorFilament" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#00ae42" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#00ae42" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
<XAxis
dataKey="dateLabel"
stroke="#9ca3af"
tick={{ fontSize: 12 }}
interval="preserveStartEnd"
/>
<YAxis
stroke="#9ca3af"
tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value}g`}
/>
<Tooltip
contentStyle={{
backgroundColor: '#2d2d2d',
border: '1px solid #3d3d3d',
borderRadius: '8px',
}}
labelStyle={{ color: '#fff' }}
formatter={(value: number) => [`${value.toFixed(0)}g`, 'Filament']}
/>
<Area
type="monotone"
dataKey="filament"
stroke="#00ae42"
strokeWidth={2}
fillOpacity={1}
fill="url(#colorFilament)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
No data for selected time range
</div>
)}
{/* Bottom Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Filament Type Distribution */}
<div className="bg-bambu-dark rounded-lg p-4">
<h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
{filamentTypeData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={filamentTypeData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
dataKey="value"
label={({ name, percent }) => `${name} ${((percent || 0) * 100).toFixed(0)}%`}
labelLine={false}
>
{filamentTypeData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#2d2d2d',
border: '1px solid #3d3d3d',
borderRadius: '8px',
}}
formatter={(value: number) => [`${value}g`, 'Usage']}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[200px] flex items-center justify-center text-bambu-gray">
No filament data
</div>
)}
</div>
{/* Monthly Comparison */}
<div className="bg-bambu-dark rounded-lg p-4">
<h4 className="text-sm font-medium text-bambu-gray mb-4">Monthly Comparison</h4>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={monthlyComparison}>
<CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 12 }} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}g`} />
<Tooltip
contentStyle={{
backgroundColor: '#2d2d2d',
border: '1px solid #3d3d3d',
borderRadius: '8px',
}}
formatter={(value: number, name: string) => [
name === 'filament' ? `${value}g` : name === 'cost' ? `${currency}${value.toFixed(2)}` : value,
name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
]}
/>
<Legend />
<Bar dataKey="filament" name="Filament (g)" fill="#00ae42" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
@@ -0,0 +1,336 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
X,
Folder,
File,
ChevronLeft,
Download,
Trash2,
Loader2,
HardDrive,
RefreshCw,
Film,
FileBox,
FileText,
Image,
Search,
} from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
interface FileManagerModalProps {
printerId: number;
printerName: string;
onClose: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatStorageSize(bytes: number): string {
if (bytes === 0) return '0 GB';
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) {
return `${gb.toFixed(1)} GB`;
}
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(0)} MB`;
}
function getFileIcon(filename: string, isDirectory: boolean) {
if (isDirectory) return Folder;
const ext = filename.toLowerCase().split('.').pop() || '';
switch (ext) {
case '3mf':
return FileBox;
case 'gcode':
return FileText;
case 'mp4':
case 'avi':
return Film;
case 'png':
case 'jpg':
case 'jpeg':
return Image;
default:
return File;
}
}
export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [currentPath, setCurrentPath] = useState('/');
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { data, isLoading, refetch } = useQuery({
queryKey: ['printerFiles', printerId, currentPath],
queryFn: () => api.getPrinterFiles(printerId, currentPath),
});
const { data: storageData } = useQuery({
queryKey: ['printerStorage', printerId],
queryFn: () => api.getPrinterStorage(printerId),
staleTime: 30000, // Cache for 30 seconds
});
const deleteMutation = useMutation({
mutationFn: (path: string) => api.deletePrinterFile(printerId, path),
onSuccess: (_, path) => {
showToast(`Deleted: ${path.split('/').pop()}`);
queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
setSelectedFile(null);
},
onError: (error: Error) => {
showToast(`Delete failed: ${error.message}`, 'error');
},
});
const navigateToFolder = (path: string) => {
setCurrentPath(path);
setSelectedFile(null);
};
const navigateUp = () => {
if (currentPath === '/') return;
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
setCurrentPath(parts.length ? '/' + parts.join('/') : '/');
setSelectedFile(null);
};
const handleDownload = (path: string) => {
window.open(api.getPrinterFileDownloadUrl(printerId, path), '_blank');
};
const handleDelete = (path: string) => {
setFileToDelete(path);
};
// Quick navigation buttons for common directories
const quickDirs = [
{ path: '/', label: 'Root' },
{ path: '/cache', label: 'Cache' },
{ path: '/model', label: 'Models' },
{ path: '/timelapse', label: 'Timelapse' },
];
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="w-full max-w-3xl max-h-[85vh] flex flex-col bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-bambu-green" />
<div>
<h2 className="text-lg font-semibold text-white">File Manager</h2>
<p className="text-sm text-bambu-gray">{printerName}</p>
</div>
</div>
<div className="flex items-center gap-4">
{/* Storage info */}
{storageData?.used_bytes != null && storageData.used_bytes > 0 && (
<div className="text-sm text-bambu-gray">
Used: {formatStorageSize(storageData.used_bytes)}
</div>
)}
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Quick Navigation */}
<div className="flex items-center gap-2 p-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
{quickDirs.map((dir) => (
<button
key={dir.path}
onClick={() => {
navigateToFolder(dir.path);
setSearchQuery('');
}}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
currentPath === dir.path
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
>
{dir.label}
</button>
))}
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Filter files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
/>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* Path breadcrumb */}
<div className="flex items-center gap-2 px-4 py-2 bg-bambu-dark text-sm flex-shrink-0">
<button
onClick={navigateUp}
disabled={currentPath === '/'}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-bambu-gray font-mono">{currentPath}</span>
</div>
{/* File list */}
<div className="flex-1 overflow-y-auto p-2 min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : !data?.files?.length ? (
<div className="text-center py-12 text-bambu-gray">
No files in this directory
</div>
) : (
<div className="space-y-1">
{/* Filter and sort: directories first, then files */}
{[...data.files]
.filter((file) =>
!searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())
)
.sort((a, b) => {
if (a.is_directory && !b.is_directory) return -1;
if (!a.is_directory && b.is_directory) return 1;
return a.name.localeCompare(b.name);
})
.map((file) => {
const FileIcon = getFileIcon(file.name, file.is_directory);
const isSelected = selectedFile === file.path;
return (
<div
key={file.path}
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
isSelected
? 'bg-bambu-green/20 border border-bambu-green/50'
: 'hover:bg-bambu-dark-tertiary'
}`}
onClick={() => {
if (file.is_directory) {
navigateToFolder(file.path);
} else {
setSelectedFile(isSelected ? null : file.path);
}
}}
>
<FileIcon
className={`w-5 h-5 flex-shrink-0 ${
file.is_directory ? 'text-bambu-green' : 'text-bambu-gray'
}`}
/>
<span className="flex-1 text-white truncate">{file.name}</span>
{!file.is_directory && (
<span className="text-sm text-bambu-gray">
{formatFileSize(file.size)}
</span>
)}
{file.is_directory && (
<ChevronLeft className="w-4 h-4 text-bambu-gray rotate-180" />
)}
</div>
);
})}
</div>
)}
</div>
{/* Action bar */}
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
<div className="text-sm text-bambu-gray">
{searchQuery
? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items`
: `${data?.files?.length || 0} items`
}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
disabled={!selectedFile}
onClick={() => selectedFile && handleDownload(selectedFile)}
>
<Download className="w-4 h-4" />
Download
</Button>
<Button
variant="secondary"
disabled={!selectedFile || deleteMutation.isPending}
onClick={() => selectedFile && handleDelete(selectedFile)}
className="text-red-400 hover:text-red-300"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{fileToDelete && (
<ConfirmModal
title="Delete File"
message={`Delete "${fileToDelete.split('/').pop()}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate(fileToDelete);
setFileToDelete(null);
}}
onCancel={() => setFileToDelete(null)}
/>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react';
import { WebGLPreview, init } from 'gcode-preview';
import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
interface BuildVolume {
x: number;
y: number;
z: number;
}
interface GcodeViewerProps {
gcodeUrl: string;
buildVolume?: BuildVolume;
className?: string;
}
export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: GcodeViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const previewRef = useRef<WebGLPreview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notSliced, setNotSliced] = useState(false);
const [currentLayer, setCurrentLayer] = useState(0);
const [totalLayers, setTotalLayers] = useState(0);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
// Initialize the preview
const preview = init({
canvas,
buildVolume: buildVolume,
backgroundColor: 0x1a1a1a,
travelColor: 0x444444,
extrusionColor: 0x00ae42,
topLayerColor: 0x00ff5a,
lastSegmentColor: 0xffffff,
lineWidth: 2,
renderTravel: false,
renderExtrusion: true,
});
previewRef.current = preview;
// Fetch and parse G-code
setLoading(true);
setError(null);
setNotSliced(false);
fetch(gcodeUrl)
.then(async response => {
if (!response.ok) {
if (response.status === 404) {
const data = await response.json().catch(() => ({}));
if (data.detail?.includes('sliced')) {
setNotSliced(true);
throw new Error('not_sliced');
}
}
throw new Error('Failed to load G-code');
}
return response.text();
})
.then(gcode => {
// Parse G-code
preview.processGCode(gcode);
// Get layer count
const layers = preview.layers?.length || 0;
setTotalLayers(layers);
setCurrentLayer(layers);
// Render all layers initially
preview.render();
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
// Handle resize
const handleResize = () => {
if (canvas.parentElement) {
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
preview.resize();
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
preview.dispose();
};
}, [gcodeUrl, buildVolume]);
const handleLayerChange = (layer: number) => {
if (!previewRef.current) return;
const newLayer = Math.max(1, Math.min(layer, totalLayers));
setCurrentLayer(newLayer);
// Clear and re-render up to the specified layer
previewRef.current.render();
};
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleLayerChange(parseInt(e.target.value, 10));
};
return (
<div className={`relative flex flex-col h-full ${className}`}>
{/* Canvas container */}
<div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-full"
/>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green mx-auto mb-2" />
<p className="text-bambu-gray text-sm">Loading G-code...</p>
</div>
</div>
)}
{notSliced && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center max-w-sm px-4">
<FileWarning className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
<p className="text-white font-medium mb-2">G-code not available</p>
<p className="text-bambu-gray text-sm">
This file hasn't been sliced yet. G-code preview is only available
after slicing the model in Bambu Studio or Orca Slicer.
</p>
</div>
</div>
)}
{error && !notSliced && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center text-red-400">
<p className="text-sm">{error}</p>
</div>
</div>
)}
</div>
{/* Layer controls */}
{!loading && !error && !notSliced && totalLayers > 0 && (
<div className="mt-4 px-2">
<div className="flex items-center gap-3">
<Layers className="w-4 h-4 text-bambu-gray flex-shrink-0" />
<button
onClick={() => handleLayerChange(currentLayer - 1)}
disabled={currentLayer <= 1}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<input
type="range"
min={1}
max={totalLayers}
value={currentLayer}
onChange={handleSliderChange}
className="flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green"
/>
<button
onClick={() => handleLayerChange(currentLayer + 1)}
disabled={currentLayer >= totalLayers}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
<span className="text-sm text-bambu-gray min-w-[80px] text-right">
{currentLayer} / {totalLayers}
</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,85 @@
import { X, Keyboard } from 'lucide-react';
import { Card, CardContent } from './Card';
interface KeyboardShortcutsModalProps {
onClose: () => void;
}
const shortcuts = [
{ category: 'Navigation', items: [
{ keys: ['1'], description: 'Go to Printers' },
{ keys: ['2'], description: 'Go to Archives' },
{ keys: ['3'], description: 'Go to Statistics' },
{ keys: ['4'], description: 'Go to Cloud Profiles' },
{ keys: ['5'], description: 'Go to Settings' },
]},
{ category: 'Archives', items: [
{ keys: ['/'], description: 'Focus search' },
{ keys: ['U'], description: 'Open upload modal' },
{ keys: ['Esc'], description: 'Clear selection / blur input' },
{ keys: ['Right-click'], description: 'Context menu on cards' },
]},
{ category: 'General', items: [
{ keys: ['?'], description: 'Show this help' },
]},
];
function KeyBadge({ children }: { children: string }) {
return (
<kbd className="px-2 py-1 text-xs font-mono bg-bambu-dark border border-bambu-dark-tertiary rounded text-white">
{children}
</kbd>
);
}
export function KeyboardShortcutsModal({ onClose }: KeyboardShortcutsModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Keyboard className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Shortcuts List */}
<div className="p-4 space-y-6 max-h-[60vh] overflow-y-auto">
{shortcuts.map((section) => (
<div key={section.category}>
<h3 className="text-sm font-medium text-bambu-gray mb-3">{section.category}</h3>
<div className="space-y-2">
{section.items.map((shortcut) => (
<div key={shortcut.description} className="flex items-center justify-between">
<span className="text-white text-sm">{shortcut.description}</span>
<div className="flex gap-1">
{shortcut.keys.map((key) => (
<KeyBadge key={key}>{key}</KeyBadge>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="p-4 border-t border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray text-center">
Press <KeyBadge>Esc</KeyBadge> or click outside to close
</p>
</div>
</CardContent>
</Card>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { useState, useEffect, useCallback } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { Printer, Archive, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
const navItems = [
{ to: '/', icon: Printer, label: 'Printers' },
{ to: '/archives', icon: Archive, label: 'Archives' },
{ to: '/stats', icon: BarChart3, label: 'Statistics' },
{ to: '/cloud', icon: Cloud, label: 'Cloud Profiles' },
{ to: '/settings', icon: Settings, label: 'Settings' },
];
export function Layout() {
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
});
const [showShortcuts, setShowShortcuts] = useState(false);
useEffect(() => {
localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
}, [sidebarExpanded]);
// Global keyboard shortcuts for navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const target = e.target as HTMLElement;
// Ignore if typing in an input/textarea
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Number keys for navigation (1-4)
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
switch (e.key) {
case '1':
e.preventDefault();
navigate('/');
break;
case '2':
e.preventDefault();
navigate('/archives');
break;
case '3':
e.preventDefault();
navigate('/stats');
break;
case '4':
e.preventDefault();
navigate('/cloud');
break;
case '5':
e.preventDefault();
navigate('/settings');
break;
case '?':
e.preventDefault();
setShowShortcuts(true);
break;
case 'Escape':
setShowShortcuts(false);
break;
}
}
}, [navigate]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside
className={`${sidebarExpanded ? 'w-64' : 'w-16'} bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col fixed inset-y-0 left-0 z-30 transition-all duration-300`}
>
{/* Logo */}
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-center overflow-hidden">
<div className={`${sidebarExpanded ? '' : 'w-10 h-10 overflow-hidden'}`}>
<img
src={theme === 'dark' ? '/img/bambusy_logo_dark.png' : '/img/bambusy_logo_light.png'}
alt="Bambusy"
className={sidebarExpanded ? 'h-16 w-auto' : 'h-10 w-auto max-w-none'}
/>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-2">
<ul className="space-y-2">
{navItems.map(({ to, icon: Icon, label }) => (
<li key={to}>
<NavLink
to={to}
className={({ isActive }) =>
`flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!sidebarExpanded ? label : undefined}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{sidebarExpanded && <span>{label}</span>}
</NavLink>
</li>
))}
</ul>
</nav>
{/* Collapse toggle */}
<button
onClick={() => setSidebarExpanded(!sidebarExpanded)}
className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
>
{sidebarExpanded ? (
<ChevronLeft className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
{/* Footer */}
<div className="p-2 border-t border-bambu-dark-tertiary">
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-2' : 'flex-col gap-2'}`}>
{sidebarExpanded && <span className="text-sm text-bambu-gray">v0.1.1</span>}
<div className="flex items-center gap-1">
<a
href="https://github.com/maziggy/bambusy"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="View on GitHub"
>
<Github className="w-5 h-5" />
</a>
<button
onClick={() => setShowShortcuts(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="Keyboard shortcuts (?)"
>
<Keyboard className="w-5 h-5" />
</button>
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
</div>
</div>
</div>
</aside>
{/* Main content */}
<main className={`flex-1 bg-bambu-dark overflow-auto ${sidebarExpanded ? 'ml-64' : 'ml-16'} transition-all duration-300`}>
<Outlet />
</main>
{/* Keyboard Shortcuts Modal */}
{showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} />}
</div>
);
}
+515
View File
@@ -0,0 +1,515 @@
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import JSZip from 'jszip';
import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from './Button';
interface BuildVolume {
x: number;
y: number;
z: number;
}
interface ModelViewerProps {
url: string;
buildVolume?: BuildVolume;
className?: string;
}
interface MeshData {
vertices: number[];
triangles: number[];
}
interface ObjectData {
id: string;
meshes: MeshData[];
}
interface BuildItem {
objectId: string;
transform: THREE.Matrix4;
}
// Parse 3MF transform - keep in 3MF coordinate space (Z-up)
function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
const matrix = new THREE.Matrix4();
if (!transformStr) {
return matrix; // Identity matrix
}
// 3MF transform is a 3x4 affine matrix in row-major order:
// "m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32"
// Where (m30, m31, m32) is the translation vector
const values = transformStr.trim().split(/\s+/).map(parseFloat);
if (values.length >= 12) {
// Three.js Matrix4.set takes row-major order arguments:
// set(n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44)
// 3MF row-major: m00, m01, m02, m10, m11, m12, m20, m21, m22, m30, m31, m32
matrix.set(
values[0], values[1], values[2], values[9], // m00, m01, m02, tx
values[3], values[4], values[5], values[10], // m10, m11, m12, ty
values[6], values[7], values[8], values[11], // m20, m21, m22, tz
0, 0, 0, 1
);
}
return matrix;
}
// Alias for backwards compatibility
const parseTransform = parseTransform3MF;
async function parseMeshFromDoc(doc: Document): Promise<MeshData[]> {
const meshes: MeshData[] = [];
const meshElements = doc.getElementsByTagName('mesh');
for (let j = 0; j < meshElements.length; j++) {
const meshEl = meshElements[j];
const vertices: number[] = [];
const triangles: number[] = [];
const vertexElements = meshEl.getElementsByTagName('vertex');
for (let k = 0; k < vertexElements.length; k++) {
const v = vertexElements[k];
vertices.push(
parseFloat(v.getAttribute('x') || '0'),
parseFloat(v.getAttribute('y') || '0'),
parseFloat(v.getAttribute('z') || '0')
);
}
const triangleElements = meshEl.getElementsByTagName('triangle');
for (let k = 0; k < triangleElements.length; k++) {
const t = triangleElements[k];
triangles.push(
parseInt(t.getAttribute('v1') || '0'),
parseInt(t.getAttribute('v2') || '0'),
parseInt(t.getAttribute('v3') || '0')
);
}
if (vertices.length > 0 && triangles.length > 0) {
meshes.push({ vertices, triangles });
}
}
return meshes;
}
async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
const zip = await JSZip.loadAsync(arrayBuffer);
const objects = new Map<string, ObjectData>();
const buildItems: BuildItem[] = [];
const parser = new DOMParser();
// Helper to load and parse a model file from the zip
async function loadModelFile(path: string): Promise<Document | null> {
// Normalize path (remove leading slash)
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
const file = zip.files[normalizedPath];
if (!file) return null;
const content = await file.async('string');
return parser.parseFromString(content, 'application/xml');
}
// Find the main 3D model file
const mainModelPath = Object.keys(zip.files).find(
(name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
);
if (!mainModelPath) {
// Fallback: try to find any .model file
const anyModelPath = Object.keys(zip.files).find((name) => name.endsWith('.model'));
if (anyModelPath) {
const doc = await loadModelFile(anyModelPath);
if (doc) {
const meshes = await parseMeshFromDoc(doc);
if (meshes.length > 0) {
objects.set('1', { id: '1', meshes });
}
}
}
return { objects, buildItems };
}
const mainDoc = await loadModelFile(mainModelPath);
if (!mainDoc) return { objects, buildItems };
// Parse objects - Bambu Studio uses components to reference external files
const objectElements = mainDoc.getElementsByTagName('object');
for (let i = 0; i < objectElements.length; i++) {
const objEl = objectElements[i];
const objectId = objEl.getAttribute('id');
if (!objectId) continue;
const meshes: MeshData[] = [];
// Check for direct mesh in this object
const objMeshElements = objEl.getElementsByTagName('mesh');
for (let j = 0; j < objMeshElements.length; j++) {
const meshEl = objMeshElements[j];
const vertices: number[] = [];
const triangles: number[] = [];
const vertexElements = meshEl.getElementsByTagName('vertex');
for (let k = 0; k < vertexElements.length; k++) {
const v = vertexElements[k];
vertices.push(
parseFloat(v.getAttribute('x') || '0'),
parseFloat(v.getAttribute('y') || '0'),
parseFloat(v.getAttribute('z') || '0')
);
}
const triangleElements = meshEl.getElementsByTagName('triangle');
for (let k = 0; k < triangleElements.length; k++) {
const t = triangleElements[k];
triangles.push(
parseInt(t.getAttribute('v1') || '0'),
parseInt(t.getAttribute('v2') || '0'),
parseInt(t.getAttribute('v3') || '0')
);
}
if (vertices.length > 0 && triangles.length > 0) {
meshes.push({ vertices, triangles });
}
}
// Check for component references (Bambu Studio style)
const componentElements = objEl.getElementsByTagName('component');
for (let j = 0; j < componentElements.length; j++) {
const compEl = componentElements[j];
// p:path attribute contains the external file reference
const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
if (extPath) {
const extDoc = await loadModelFile(extPath);
if (extDoc) {
const extMeshes = await parseMeshFromDoc(extDoc);
// Apply component transform if present
const compTransformStr = compEl.getAttribute('transform');
const compTransform = parseTransform(compTransformStr);
for (const mesh of extMeshes) {
if (compTransformStr) {
// Apply transform to vertices (in 3MF coordinate space, before Y/Z swap)
const transformedVertices: number[] = [];
for (let k = 0; k < mesh.vertices.length; k += 3) {
const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]);
v.applyMatrix4(compTransform);
transformedVertices.push(v.x, v.y, v.z);
}
meshes.push({ vertices: transformedVertices, triangles: mesh.triangles });
} else {
meshes.push(mesh);
}
}
}
}
}
if (meshes.length > 0) {
objects.set(objectId, { id: objectId, meshes });
}
}
// Parse build items (placement on build plate)
const buildElements = mainDoc.getElementsByTagName('build');
if (buildElements.length > 0) {
const itemElements = buildElements[0].getElementsByTagName('item');
for (let i = 0; i < itemElements.length; i++) {
const itemEl = itemElements[i];
const objectId = itemEl.getAttribute('objectid');
if (!objectId) continue;
const transform = parseTransform(itemEl.getAttribute('transform'));
buildItems.push({ objectId, transform });
}
}
return { objects, buildItems };
}
function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry();
// Convert from 3MF Z-up to Three.js Y-up coordinate system
// 3MF: X right, Y back, Z up -> Three.js: X right, Y up, Z forward
const positions = new Float32Array(mesh.vertices.length);
for (let i = 0; i < mesh.vertices.length; i += 3) {
positions[i] = mesh.vertices[i]; // X stays X
positions[i + 1] = mesh.vertices[i + 2]; // Y becomes Z (up)
positions[i + 2] = mesh.vertices[i + 1]; // Z becomes Y
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex(mesh.triangles);
// Compute normals
geometry.computeVertexNormals();
return geometry;
}
export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: ModelViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
sceneRef.current = scene;
// Camera
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
camera.position.set(150, 150, 150);
cameraRef.current = camera;
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controlsRef.current = controls;
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(100, 100, 100);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
directionalLight2.position.set(-100, 50, -100);
scene.add(directionalLight2);
// Grid - use the larger dimension for the grid size
const gridSize = Math.max(buildVolume.x, buildVolume.y);
const gridDivisions = Math.ceil(gridSize / 16);
const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
scene.add(gridHelper);
// Build plate indicator
const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
const plateMaterial = new THREE.MeshBasicMaterial({
color: 0x00ae42,
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide,
});
const plate = new THREE.Mesh(plateGeometry, plateMaterial);
plate.rotation.x = -Math.PI / 2;
plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
scene.add(plate);
// Animation loop - keep it simple for reliability
let animationId: number;
const animate = () => {
animationId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
// Load 3MF
fetch(url)
.then((res) => {
if (!res.ok) throw new Error('Failed to load file');
return res.arrayBuffer();
})
.then(parse3MF)
.then(({ objects, buildItems }) => {
if (objects.size === 0) {
throw new Error('No meshes found in 3MF file');
}
const material = new THREE.MeshPhongMaterial({
color: 0x00ae42,
shininess: 30,
flatShading: false,
});
const group = new THREE.Group();
const allGeometries: THREE.BufferGeometry[] = [];
// If we have build items, use them for positioning
if (buildItems.length > 0) {
for (const item of buildItems) {
const objectData = objects.get(item.objectId);
if (!objectData) continue;
for (const meshData of objectData.meshes) {
// Apply build transform to vertices in 3MF space BEFORE coordinate conversion
const transformedVertices: number[] = [];
for (let k = 0; k < meshData.vertices.length; k += 3) {
const v = new THREE.Vector3(
meshData.vertices[k],
meshData.vertices[k + 1],
meshData.vertices[k + 2]
);
v.applyMatrix4(item.transform);
transformedVertices.push(v.x, v.y, v.z);
}
// Now create geometry with coordinate conversion
const geometry = createGeometryFromMesh({
vertices: transformedVertices,
triangles: meshData.triangles,
});
allGeometries.push(geometry);
}
}
} else {
// Fallback: just add all objects without transforms
for (const objectData of objects.values()) {
for (const meshData of objectData.meshes) {
const geometry = createGeometryFromMesh(meshData);
allGeometries.push(geometry);
}
}
}
// Merge all geometries into one for better performance
if (allGeometries.length > 0) {
const mergedGeometry = allGeometries.length === 1
? allGeometries[0]
: mergeGeometries(allGeometries, false);
if (mergedGeometry) {
const mesh = new THREE.Mesh(mergedGeometry, material);
group.add(mesh);
}
// Dispose individual geometries if merged
if (allGeometries.length > 1) {
for (const geom of allGeometries) {
geom.dispose();
}
}
}
// Get bounding box to position model
const box = new THREE.Box3().setFromObject(group);
const center = box.getCenter(new THREE.Vector3());
// Always place models on the build plate (Y=0)
group.position.y = -box.min.y;
// For models without build transforms, also center X/Z
if (buildItems.length === 0) {
group.position.x = -center.x;
group.position.z = -center.z;
}
scene.add(group);
// Recalculate bounding box after positioning
const finalBox = new THREE.Box3().setFromObject(group);
const finalCenter = finalBox.getCenter(new THREE.Vector3());
const finalSize = finalBox.getSize(new THREE.Vector3());
// Adjust camera to fit model
const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
const cameraDistance = maxDim * 1.8;
camera.position.set(
finalCenter.x + cameraDistance * 0.7,
finalCenter.y + cameraDistance * 0.5,
finalCenter.z + cameraDistance * 0.7
);
controls.target.copy(finalCenter);
controls.update();
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
// Handle resize
const handleResize = () => {
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(animationId);
controls.dispose();
renderer.dispose();
container.removeChild(renderer.domElement);
};
}, [url, buildVolume]);
const resetView = () => {
if (cameraRef.current && controlsRef.current) {
cameraRef.current.position.set(150, 150, 150);
controlsRef.current.target.set(0, 50, 0);
controlsRef.current.update();
}
};
const zoom = (factor: number) => {
if (cameraRef.current) {
cameraRef.current.position.multiplyScalar(factor);
}
};
return (
<div className={`relative ${className}`}>
<div ref={containerRef} className="w-full h-full min-h-[400px]" />
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<p className="text-red-400">{error}</p>
</div>
)}
{!loading && !error && (
<div className="absolute bottom-4 right-4 flex gap-2">
<Button variant="secondary" size="sm" onClick={() => zoom(0.8)}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={() => zoom(1.25)}>
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={resetView}>
<RotateCcw className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
import { ModelViewer } from './ModelViewer';
import { GcodeViewer } from './GcodeViewer';
import { Button } from './Button';
import { api } from '../api/client';
type ViewTab = '3d' | 'gcode';
interface ModelViewerModalProps {
archiveId: number;
title: string;
onClose: () => void;
}
interface Capabilities {
has_model: boolean;
has_gcode: boolean;
build_volume: { x: number; y: number; z: number };
}
export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
const [loading, setLoading] = useState(true);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
api.getArchiveCapabilities(archiveId)
.then(caps => {
setCapabilities(caps);
// Auto-select the first available tab
if (caps.has_model) {
setActiveTab('3d');
} else if (caps.has_gcode) {
setActiveTab('gcode');
}
setLoading(false);
})
.catch(() => {
// Fallback to 3D model tab if capabilities check fails
setCapabilities({ has_model: true, has_gcode: false, build_volume: { x: 256, y: 256, z: 256 } });
setActiveTab('3d');
setLoading(false);
});
}, [archiveId]);
const handleOpenInSlicer = () => {
// Use bambustudioopen:// protocol like MakerWorld does
// URL must include .3mf filename for Bambu Studio to recognize the format
const filename = title || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
};
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
<ExternalLink className="w-4 h-4" />
Open in Slicer
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{/* Tabs - only show if we have capabilities */}
{capabilities && (
<div className="flex border-b border-bambu-dark-tertiary">
<button
onClick={() => capabilities.has_model && setActiveTab('3d')}
disabled={!capabilities.has_model}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === '3d'
? 'text-bambu-green border-b-2 border-bambu-green'
: capabilities.has_model
? 'text-bambu-gray hover:text-white'
: 'text-bambu-gray/30 cursor-not-allowed'
}`}
>
<Box className="w-4 h-4" />
3D Model
{!capabilities.has_model && <span className="text-xs">(not available)</span>}
</button>
<button
onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
disabled={!capabilities.has_gcode}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === 'gcode'
? 'text-bambu-green border-b-2 border-bambu-green'
: capabilities.has_gcode
? 'text-bambu-gray hover:text-white'
: 'text-bambu-gray/30 cursor-not-allowed'
}`}
>
<Code2 className="w-4 h-4" />
G-code Preview
{!capabilities.has_gcode && <span className="text-xs">(not sliced)</span>}
</button>
</div>
)}
{/* Viewer */}
<div className="flex-1 overflow-hidden p-4">
{loading ? (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
) : activeTab === '3d' && capabilities ? (
<ModelViewer
url={api.getArchiveDownload(archiveId)}
buildVolume={capabilities.build_volume}
className="w-full h-full"
/>
) : activeTab === 'gcode' && capabilities ? (
<GcodeViewer
gcodeUrl={api.getArchiveGcode(archiveId)}
buildVolume={capabilities.build_volume}
className="w-full h-full"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
No preview available for this file
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2 } from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
interface PhotoGalleryModalProps {
archiveId: number;
archiveName: string;
photos: string[];
onClose: () => void;
onDelete?: (filename: string) => void;
}
export function PhotoGalleryModal({
archiveId,
archiveName,
photos,
onClose,
onDelete,
}: PhotoGalleryModalProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
if (e.key === 'ArrowRight') setCurrentIndex((i) => Math.min(photos.length - 1, i + 1));
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, photos.length]);
// Reset index if photos change
useEffect(() => {
if (currentIndex >= photos.length) {
setCurrentIndex(Math.max(0, photos.length - 1));
}
}, [photos.length, currentIndex]);
if (photos.length === 0) {
onClose();
return null;
}
const currentPhoto = photos[currentIndex];
const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);
const handleDownload = () => {
const link = document.createElement('a');
link.href = photoUrl;
link.download = `${archiveName}_photo_${currentIndex + 1}.jpg`;
link.click();
};
const handleDelete = () => {
if (onDelete) {
setShowDeleteConfirm(true);
}
};
return (
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="relative w-full h-full flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-black/50">
<div>
<h2 className="text-lg font-semibold text-white">{archiveName}</h2>
<p className="text-sm text-bambu-gray">
Photo {currentIndex + 1} of {photos.length}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={handleDownload}>
<Download className="w-4 h-4" />
Download
</Button>
{onDelete && (
<Button variant="secondary" size="sm" onClick={handleDelete} className="text-red-400 hover:text-red-300">
<Trash2 className="w-4 h-4" />
</Button>
)}
<button
onClick={onClose}
className="p-2 text-bambu-gray hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* Image */}
<div className="flex-1 min-h-0 flex items-center justify-center p-4 relative overflow-hidden">
{/* Previous button */}
{currentIndex > 0 && (
<button
onClick={() => setCurrentIndex((i) => i - 1)}
className="absolute left-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
>
<ChevronLeft className="w-8 h-8 text-white" />
</button>
)}
{/* Image */}
<img
src={photoUrl}
alt={`Photo ${currentIndex + 1}`}
className="max-w-full max-h-full object-contain rounded-lg"
style={{ maxHeight: 'calc(100vh - 200px)' }}
/>
{/* Next button */}
{currentIndex < photos.length - 1 && (
<button
onClick={() => setCurrentIndex((i) => i + 1)}
className="absolute right-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
>
<ChevronRight className="w-8 h-8 text-white" />
</button>
)}
</div>
{/* Thumbnails */}
{photos.length > 1 && (
<div className="flex justify-center gap-2 p-4 bg-black/50">
{photos.map((photo, index) => (
<button
key={photo}
onClick={() => setCurrentIndex(index)}
className={`w-16 h-16 rounded-lg overflow-hidden border-2 transition-colors ${
index === currentIndex
? 'border-bambu-green'
: 'border-transparent hover:border-bambu-gray'
}`}
>
<img
src={api.getArchivePhotoUrl(archiveId, photo)}
alt={`Thumbnail ${index + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Photo"
message="Delete this photo? This cannot be undone."
confirmText="Delete"
variant="danger"
onConfirm={() => {
onDelete?.(currentPhoto);
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useMemo } from 'react';
interface PrintCalendarProps {
printDates: string[]; // Array of ISO date strings
months?: number; // How many months to show (default 3)
}
export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
const { weeks, monthLabels, printCounts } = useMemo(() => {
// Count prints per day
const counts: Record<string, number> = {};
printDates.forEach((date) => {
const day = date.split('T')[0];
counts[day] = (counts[day] || 0) + 1;
});
// Generate weeks for the last N months
const today = new Date();
const startDate = new Date(today);
startDate.setMonth(startDate.getMonth() - months);
startDate.setDate(startDate.getDate() - startDate.getDay()); // Start from Sunday
const weeks: Date[][] = [];
const monthLabels: { month: string; weekIndex: number }[] = [];
let currentWeek: Date[] = [];
let lastMonth = -1;
const current = new Date(startDate);
let weekIndex = 0;
while (current <= today) {
if (current.getDay() === 0 && currentWeek.length > 0) {
weeks.push(currentWeek);
currentWeek = [];
weekIndex++;
}
// Track month labels
if (current.getMonth() !== lastMonth) {
monthLabels.push({
month: current.toLocaleDateString('en-US', { month: 'short' }),
weekIndex,
});
lastMonth = current.getMonth();
}
currentWeek.push(new Date(current));
current.setDate(current.getDate() + 1);
}
if (currentWeek.length > 0) {
weeks.push(currentWeek);
}
return { weeks, monthLabels, printCounts: counts };
}, [printDates, months]);
const maxCount = Math.max(1, ...Object.values(printCounts));
const getColor = (count: number) => {
if (count === 0) return 'bg-bambu-dark';
const intensity = count / maxCount;
if (intensity <= 0.25) return 'bg-bambu-green/30';
if (intensity <= 0.5) return 'bg-bambu-green/50';
if (intensity <= 0.75) return 'bg-bambu-green/75';
return 'bg-bambu-green';
};
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return (
<div className="overflow-x-auto">
{/* Month labels */}
<div className="flex mb-1 ml-8">
{monthLabels.map(({ month, weekIndex }, i) => (
<div
key={i}
className="text-xs text-bambu-gray"
style={{ marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * 14 - 24}px` }}
>
{month}
</div>
))}
</div>
<div className="flex gap-0.5">
{/* Day labels */}
<div className="flex flex-col gap-0.5 mr-1">
{dayLabels.map((day, i) => (
<div
key={day}
className="h-3 text-xs text-bambu-gray flex items-center"
style={{ visibility: i % 2 === 1 ? 'visible' : 'hidden' }}
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-0.5">
{[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
const day = week.find((d) => d.getDay() === dayOfWeek);
if (!day) {
return <div key={dayOfWeek} className="w-3 h-3" />;
}
const dateStr = day.toISOString().split('T')[0];
const count = printCounts[dateStr] || 0;
const isToday = dateStr === new Date().toISOString().split('T')[0];
return (
<div
key={dayOfWeek}
className={`w-3 h-3 rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
/>
);
})}
</div>
))}
</div>
{/* Legend */}
<div className="flex items-center gap-2 mt-3 text-xs text-bambu-gray">
<span>Less</span>
<div className="flex gap-0.5">
<div className="w-3 h-3 rounded-sm bg-bambu-dark" />
<div className="w-3 h-3 rounded-sm bg-bambu-green/30" />
<div className="w-3 h-3 rounded-sm bg-bambu-green/50" />
<div className="w-3 h-3 rounded-sm bg-bambu-green/75" />
<div className="w-3 h-3 rounded-sm bg-bambu-green" />
</div>
<span>More</span>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { X, Download } from 'lucide-react';
import { Button } from './Button';
import { api } from '../api/client';
interface QRCodeModalProps {
archiveId: number;
archiveName: string;
onClose: () => void;
}
export function QRCodeModal({ archiveId, archiveName, onClose }: QRCodeModalProps) {
const qrCodeUrl = api.getArchiveQRCodeUrl(archiveId, 300);
const handleDownload = () => {
const link = document.createElement('a');
link.href = qrCodeUrl;
link.download = `${archiveName}_qrcode.png`;
link.click();
};
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-sm"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">QR Code</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 flex flex-col items-center">
<p className="text-sm text-bambu-gray mb-4 text-center truncate max-w-full">
{archiveName}
</p>
<div className="bg-white p-4 rounded-lg mb-4">
<img
src={qrCodeUrl}
alt="QR Code"
className="w-64 h-64"
/>
</div>
<p className="text-xs text-bambu-gray mb-4 text-center">
Scan to open this archive
</p>
<Button onClick={handleDownload} className="w-full">
<Download className="w-4 h-4" />
Download QR Code
</Button>
</div>
</div>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { X, Printer, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
interface ReprintModalProps {
archiveId: number;
archiveName: string;
onClose: () => void;
onSuccess: () => void;
}
export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
const { data: printers, isLoading: loadingPrinters } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const reprintMutation = useMutation({
mutationFn: () => {
if (!selectedPrinter) throw new Error('No printer selected');
return api.reprintArchive(archiveId, selectedPrinter);
},
onSuccess: () => {
onSuccess();
onClose();
},
});
const activePrinters = printers?.filter((p) => p.is_active) || [];
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
<Card className="w-full max-w-md">
<CardContent>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Re-print</h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<p className="text-sm text-bambu-gray mb-4">
Send <span className="text-white">{archiveName}</span> to a printer
</p>
{/* Printer selection */}
{loadingPrinters ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : activePrinters.length === 0 ? (
<div className="text-center py-8 text-bambu-gray">
No active printers available
</div>
) : (
<div className="space-y-2 mb-6">
{activePrinters.map((printer) => (
<button
key={printer.id}
onClick={() => setSelectedPrinter(printer.id)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
selectedPrinter === printer.id
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
}`}
>
<div
className={`p-2 rounded-lg ${
selectedPrinter === printer.id
? 'bg-bambu-green/20'
: 'bg-bambu-dark-tertiary'
}`}
>
<Printer
className={`w-5 h-5 ${
selectedPrinter === printer.id
? 'text-bambu-green'
: 'text-bambu-gray'
}`}
/>
</div>
<div className="text-left">
<p className="text-white font-medium">{printer.name}</p>
<p className="text-xs text-bambu-gray">
{printer.model || 'Unknown model'} • {printer.ip_address}
</p>
</div>
</button>
))}
</div>
)}
{/* Error message */}
{reprintMutation.isError && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
{(reprintMutation.error as Error).message || 'Failed to start print'}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
onClick={() => reprintMutation.mutate()}
disabled={!selectedPrinter || reprintMutation.isPending}
className="flex-1"
>
{reprintMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sending...
</>
) : (
<>
<Printer className="w-4 h-4" />
Print
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+302
View File
@@ -0,0 +1,302 @@
import { useState, useCallback, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import type { BulkUploadResult } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface FileWithStatus {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
archiveId?: number;
}
interface UploadModalProps {
onClose: () => void;
initialFiles?: File[];
}
export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<FileWithStatus[]>(() =>
initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
);
const [isDragging, setIsDragging] = useState(false);
const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const uploadMutation = useMutation({
mutationFn: (filesToUpload: File[]) =>
api.uploadArchivesBulk(filesToUpload, selectedPrinter),
onSuccess: (result) => {
setUploadResult(result);
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
// Update file statuses based on result
setFiles((prev) =>
prev.map((f) => {
const success = result.results.find((r) => r.filename === f.file.name);
const error = result.errors.find((e) => e.filename === f.file.name);
if (success) {
return { ...f, status: 'success', archiveId: success.id };
}
if (error) {
return { ...f, status: 'error', error: error.error };
}
return f;
})
);
// Show toast
if (result.failed === 0) {
showToast(`${result.uploaded} file${result.uploaded !== 1 ? 's' : ''} uploaded`);
} else if (result.uploaded === 0) {
showToast(`Failed to upload ${result.failed} file${result.failed !== 1 ? 's' : ''}`, 'error');
} else {
showToast(`${result.uploaded} uploaded, ${result.failed} failed`, 'warning');
}
},
onError: () => {
setFiles((prev) =>
prev.map((f) => ({ ...f, status: 'error', error: 'Upload failed' }))
);
showToast('Upload failed', 'error');
},
});
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter((f) =>
f.name.endsWith('.3mf')
);
if (droppedFiles.length > 0) {
setFiles((prev) => [
...prev,
...droppedFiles.map((file) => ({ file, status: 'pending' as const })),
]);
}
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []).filter((f) =>
f.name.endsWith('.3mf')
);
if (selectedFiles.length > 0) {
setFiles((prev) => [
...prev,
...selectedFiles.map((file) => ({ file, status: 'pending' as const })),
]);
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, []);
const removeFile = useCallback((index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleUpload = () => {
if (files.length === 0) return;
const pendingFiles = files.filter((f) => f.status === 'pending');
if (pendingFiles.length === 0) return;
setFiles((prev) =>
prev.map((f) =>
f.status === 'pending' ? { ...f, status: 'uploading' } : f
)
);
uploadMutation.mutate(pendingFiles.map((f) => f.file));
};
const pendingCount = files.filter((f) => f.status === 'pending').length;
const isUploading = uploadMutation.isPending;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-2xl 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">
<h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Drop Zone */}
<div className="p-4">
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
<p className="text-white mb-2">
Drag & drop .3mf files here
</p>
<p className="text-bambu-gray text-sm mb-4">or</p>
<Button
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
Browse Files
</Button>
<input
ref={fileInputRef}
type="file"
accept=".3mf"
multiple
className="hidden"
onChange={handleFileSelect}
/>
</div>
</div>
{/* Optional Printer Selection */}
<div className="px-4 pb-4">
<label className="block text-sm text-bambu-gray mb-2">
Associate with printer (optional)
</label>
<select
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"
value={selectedPrinter || ''}
onChange={(e) =>
setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
}
disabled={isUploading}
>
<option value="">No printer</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* File List */}
{files.length > 0 && (
<div className="px-4 pb-4 max-h-60 overflow-y-auto">
<div className="space-y-2">
{files.map((f, index) => (
<div
key={`${f.file.name}-${index}`}
className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg"
>
<File className="w-5 h-5 text-bambu-gray flex-shrink-0" />
<span className="flex-1 text-white text-sm truncate">
{f.file.name}
</span>
<span className="text-xs text-bambu-gray">
{(f.file.size / (1024 * 1024)).toFixed(1)} MB
</span>
{f.status === 'pending' && (
<button
onClick={() => removeFile(index)}
className="text-bambu-gray hover:text-red-400 transition-colors"
disabled={isUploading}
>
<X className="w-4 h-4" />
</button>
)}
{f.status === 'uploading' && (
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
)}
{f.status === 'success' && (
<CheckCircle className="w-4 h-4 text-bambu-green" />
)}
{f.status === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400">{f.error}</span>
<AlertCircle className="w-4 h-4 text-red-400" />
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Upload Result Summary */}
{uploadResult && (
<div className="px-4 pb-4">
<div className="p-3 bg-bambu-dark rounded-lg">
<p className="text-sm text-white">
<span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
{uploadResult.failed > 0 && (
<>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
)}
</p>
</div>
</div>
)}
{/* Footer */}
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose} className="flex-1">
{uploadResult ? 'Close' : 'Cancel'}
</Button>
{!uploadResult && (
<Button
onClick={handleUpload}
disabled={pendingCount === 0 || isUploading}
className="flex-1"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount > 0 && `(${pendingCount})`}
</>
)}
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) return stored;
// Check system preference
if (window.matchMedia('(prefers-color-scheme: light)').matches) {
return 'light';
}
return 'dark';
});
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
+81
View File
@@ -0,0 +1,81 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react';
type ToastType = 'success' | 'error' | 'warning' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastContextType {
showToast: (message: string, type?: ToastType) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
const icons = {
success: <CheckCircle className="w-5 h-5 text-green-400" />,
error: <XCircle className="w-5 h-5 text-red-400" />,
warning: <AlertCircle className="w-5 h-5 text-yellow-400" />,
info: <Info className="w-5 h-5 text-blue-400" />,
};
const bgColors = {
success: 'bg-green-500/10 border-green-500/30',
error: 'bg-red-500/10 border-red-500/30',
warning: 'bg-yellow-500/10 border-yellow-500/30',
info: 'bg-blue-500/10 border-blue-500/30',
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'success') => {
const id = Math.random().toString(36).substr(2, 9);
setToasts((prev) => [...prev, { id, message, type }]);
// Auto-dismiss after 3 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast Container */}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]}`}
>
{icons[toast.type]}
<span className="text-white text-sm">{toast.message}</span>
<button
onClick={() => dismissToast(toast.id)}
className="ml-2 text-bambu-gray hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
interface WebSocketMessage {
type: string;
printer_id?: number;
data?: Record<string, unknown>;
}
export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const queryClient = useQueryClient();
const [isConnected, setIsConnected] = useState(false);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setIsConnected(true);
// Start ping interval
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
ws.onclose = () => {
clearInterval(pingInterval);
};
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
handleMessage(message);
} catch {
// Ignore parse errors
}
};
ws.onclose = () => {
setIsConnected(false);
wsRef.current = null;
// Reconnect after 3 seconds
reconnectTimeoutRef.current = window.setTimeout(() => {
connect();
}, 3000);
};
ws.onerror = () => {
ws.close();
};
wsRef.current = ws;
}, []);
const handleMessage = useCallback((message: WebSocketMessage) => {
switch (message.type) {
case 'printer_status':
// Update the printer status in the query cache
if (message.printer_id !== undefined) {
queryClient.setQueryData(
['printerStatus', message.printer_id],
(old: Record<string, unknown> | undefined) => ({
...old,
...message.data,
})
);
}
break;
case 'print_complete':
// Invalidate archives to refresh the list
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
break;
case 'archive_created':
// Invalidate archives to show new archive
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
break;
case 'pong':
// Keepalive response, ignore
break;
}
}, [queryClient]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
const sendMessage = useCallback((message: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
}
}, []);
return { isConnected, sendMessage };
}
+91
View File
@@ -0,0 +1,91 @@
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@theme {
/* Bambu Lab brand colors - always the same */
--color-bambu-green: #00ae42;
--color-bambu-green-light: #00c64d;
--color-bambu-green-dark: #009438;
/* Theme-aware colors via CSS variables */
--color-bambu-dark: var(--bg-primary);
--color-bambu-dark-secondary: var(--bg-secondary);
--color-bambu-dark-tertiary: var(--bg-tertiary);
--color-bambu-gray: var(--text-muted);
--color-bambu-gray-light: var(--text-secondary);
--color-bambu-gray-dark: var(--text-tertiary);
}
/* Light mode (default) */
:root {
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--bg-tertiary: #e5e5e5;
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
--text-muted: #6b6b6b;
--text-tertiary: #808080;
--border-color: #d4d4d4;
font-family: 'Inter', system-ui, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Dark mode */
.dark {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3d3d3d;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #808080;
--text-tertiary: #4a4a4a;
--border-color: #3d3d3d;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
margin: 0;
min-height: 100vh;
transition: background-color 0.2s ease, color 0.2s ease;
}
#root {
min-height: 100vh;
}
/* Override text-white to be theme-aware */
.text-white {
color: var(--text-primary);
}
/* Smooth transitions for theme changes */
.bg-bambu-dark,
.bg-bambu-dark-secondary,
.bg-bambu-dark-tertiary,
.border-bambu-dark-tertiary {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
/* Toast slide-in animation */
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.2s ease-out;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
File diff suppressed because it is too large Load Diff
+495
View File
@@ -0,0 +1,495 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Cloud,
LogIn,
LogOut,
Loader2,
ChevronDown,
ChevronRight,
Settings2,
Printer,
Droplet,
X,
Key,
RefreshCw,
} from 'lucide-react';
import { api } from '../api/client';
import type { SlicerSetting, SlicerSettingsResponse } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
type LoginStep = 'email' | 'code' | 'token';
function LoginForm({ onSuccess }: { onSuccess: () => void }) {
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 loginMutation = useMutation({
mutationFn: () => api.cloudLogin(email, password, region),
onSuccess: (result) => {
if (result.success) {
showToast('Logged in successfully');
onSuccess();
} else if (result.needs_verification) {
showToast('Verification code sent to your email');
setStep('code');
} else {
showToast(result.message, 'error');
}
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const verifyMutation = useMutation({
mutationFn: () => api.cloudVerify(email, code),
onSuccess: (result) => {
if (result.success) {
showToast('Logged in successfully');
onSuccess();
} else {
showToast(result.message, 'error');
}
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const tokenMutation = useMutation({
mutationFn: () => api.cloudSetToken(token),
onSuccess: () => {
showToast('Token set successfully');
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">
<CardHeader>
<div className="flex items-center gap-2">
<Cloud className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Connect to Bambu Cloud</h2>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{step === 'email' && (
<>
<div>
<label className="block text-sm text-bambu-gray mb-1">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 focus:border-bambu-green focus:outline-none"
placeholder="your@email.com"
required
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">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 focus:border-bambu-green focus:outline-none"
placeholder="••••••••"
required
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">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">Global</option>
<option value="china">China</option>
</select>
</div>
</>
)}
{step === 'code' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">
Verification Code
</label>
<p className="text-xs text-bambu-gray mb-2">
Check your email ({email}) for a 6-digit code
</p>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
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"
placeholder="000000"
maxLength={6}
required
/>
</div>
)}
{step === 'token' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">
Access Token
</label>
<p className="text-xs text-bambu-gray mb-2">
Paste your Bambu Lab access token (from Bambu Studio)
</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 focus:border-bambu-green focus:outline-none resize-none"
placeholder="eyJ..."
rows={3}
required
/>
</div>
)}
<div className="flex gap-2">
{step === 'code' && (
<Button
type="button"
variant="secondary"
onClick={() => setStep('email')}
className="flex-1"
>
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' ? 'Login' : step === 'code' ? 'Verify' : 'Set Token'}
</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-1"
>
<Key className="w-3 h-3" />
Use access token instead
</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-1"
>
<LogIn className="w-3 h-3" />
Login with email instead
</button>
</div>
)}
</form>
</CardContent>
</Card>
);
}
function SettingCard({
setting,
onClick,
}: {
setting: SlicerSetting;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="w-full text-left p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
>
<p className="text-white font-medium truncate">{setting.name}</p>
{setting.updated_time && (
<p className="text-xs text-bambu-gray mt-1">
Updated: {new Date(setting.updated_time).toLocaleDateString()}
</p>
)}
</button>
);
}
function SettingDetailModal({
setting,
onClose,
}: {
setting: SlicerSetting;
onClose: () => void;
}) {
const { data: detail, isLoading } = useQuery({
queryKey: ['cloudSettingDetail', setting.setting_id],
queryFn: () => api.getCloudSettingDetail(setting.setting_id),
});
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
<CardContent className="p-0 flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div>
<h2 className="text-xl font-semibold text-white">{setting.name}</h2>
<p className="text-sm text-bambu-gray capitalize">{setting.type} preset</p>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex justify-center py-8">
<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 overflow-x-auto bg-bambu-dark p-4 rounded-lg">
{JSON.stringify(detail, null, 2)}
</pre>
) : (
<p className="text-bambu-gray text-center py-8">
Failed to load preset details
</p>
)}
</div>
<div className="p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose} className="w-full">
Close
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
function ProfilesView({ settings }: { settings: SlicerSettingsResponse }) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [selectedSetting, setSelectedSetting] = useState<SlicerSetting | null>(null);
// Sort items alphabetically by name
const sortByName = (items: SlicerSetting[]) =>
[...items].sort((a, b) => a.name.localeCompare(b.name));
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const sections = [
{
key: 'filament',
label: 'Filament Presets',
icon: Droplet,
items: sortByName(settings.filament),
},
{
key: 'printer',
label: 'Printer Presets',
icon: Printer,
items: sortByName(settings.printer),
},
{
key: 'process',
label: 'Process Presets',
icon: Settings2,
items: sortByName(settings.process),
},
];
return (
<>
<div className="space-y-4">
{sections.map(({ key, label, icon: Icon, items }) => (
<Card key={key}>
<button
onClick={() => toggleSection(key)}
className="w-full flex items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-bambu-green" />
<span className="text-lg font-semibold text-white">{label}</span>
<span className="text-sm text-bambu-gray">({items.length})</span>
</div>
{expandedSections.has(key) ? (
<ChevronDown className="w-5 h-5 text-bambu-gray" />
) : (
<ChevronRight className="w-5 h-5 text-bambu-gray" />
)}
</button>
{expandedSections.has(key) && items.length > 0 && (
<CardContent className="pt-0">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<SettingCard
key={item.setting_id}
setting={item}
onClick={() => setSelectedSetting(item)}
/>
))}
</div>
</CardContent>
)}
{expandedSections.has(key) && items.length === 0 && (
<CardContent className="pt-0">
<p className="text-bambu-gray text-sm">No presets found</p>
</CardContent>
)}
</Card>
))}
</div>
{selectedSetting && (
<SettingDetailModal
setting={selectedSetting}
onClose={() => setSelectedSetting(null)}
/>
)}
</>
);
}
export function CloudProfilesPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['cloudStatus'],
queryFn: api.getCloudStatus,
});
const { data: settings, isLoading: settingsLoading, refetch: refetchSettings } = useQuery({
queryKey: ['cloudSettings'],
queryFn: () => api.getCloudSettings(),
enabled: !!status?.is_authenticated,
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
});
const logoutMutation = useMutation({
mutationFn: api.cloudLogout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
queryClient.removeQueries({ queryKey: ['cloudSettings'] });
showToast('Logged out');
},
});
const handleLoginSuccess = () => {
queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
};
if (statusLoading) {
return (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Cloud className="w-6 h-6 text-bambu-green" />
Cloud Profiles
</h1>
<p className="text-bambu-gray">
{status?.is_authenticated
? `Connected as ${status.email}`
: 'Manage your Bambu Cloud slicer presets'}
</p>
</div>
{status?.is_authenticated && (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => refetchSettings()}
disabled={settingsLoading}
>
<RefreshCw className={`w-4 h-4 ${settingsLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button
variant="secondary"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
)}
</div>
{!status?.is_authenticated ? (
<LoginForm onSuccess={handleLoginSuccess} />
) : settingsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : settings ? (
<ProfilesView settings={settings} />
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-bambu-gray">Failed to load profiles</p>
<Button className="mt-4" onClick={() => refetchSettings()}>
Retry
</Button>
</CardContent>
</Card>
)}
</div>
);
}
+488
View File
@@ -0,0 +1,488 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Plus,
Wifi,
WifiOff,
Thermometer,
Clock,
MoreVertical,
Trash2,
RefreshCw,
Box,
HardDrive,
} from 'lucide-react';
import { api } from '../api/client';
import type { Printer, PrinterCreate } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { FileManagerModal } from '../components/FileManagerModal';
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function CoverImage({ url, printName }: { url: string | null; printName?: string }) {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const [showOverlay, setShowOverlay] = useState(false);
return (
<>
<div
className={`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${url && loaded ? 'cursor-pointer' : ''}`}
onClick={() => url && loaded && setShowOverlay(true)}
>
{url && !error ? (
<>
<img
src={url}
alt="Print preview"
className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
/>
{!loaded && <Box className="w-8 h-8 text-bambu-gray" />}
</>
) : (
<Box className="w-8 h-8 text-bambu-gray" />
)}
</div>
{/* Cover Image Overlay */}
{showOverlay && url && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8"
onClick={() => setShowOverlay(false)}
>
<div className="relative max-w-2xl max-h-full">
<img
src={url}
alt="Print preview"
className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
/>
{printName && (
<p className="text-white text-center mt-4 text-lg">{printName}</p>
)}
</div>
</div>
)}
</>
);
}
function PrinterCard({ printer }: { printer: Printer }) {
const queryClient = useQueryClient();
const [showMenu, setShowMenu] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showFileManager, setShowFileManager] = useState(false);
const { data: status } = useQuery({
queryKey: ['printerStatus', printer.id],
queryFn: () => api.getPrinterStatus(printer.id),
refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
});
const deleteMutation = useMutation({
mutationFn: () => api.deletePrinter(printer.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printers'] });
},
});
const connectMutation = useMutation({
mutationFn: () => api.connectPrinter(printer.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
},
});
return (
<Card className="relative">
<CardContent>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-white">{printer.name}</h3>
<p className="text-sm text-bambu-gray">{printer.model || 'Unknown Model'}</p>
</div>
<div className="flex items-center gap-2">
<span
className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${
status?.connected
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-red-500/20 text-red-400'
}`}
>
{status?.connected ? (
<Wifi className="w-3 h-3" />
) : (
<WifiOff className="w-3 h-3" />
)}
{status?.connected ? 'Connected' : 'Offline'}
</span>
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setShowMenu(!showMenu)}
>
<MoreVertical className="w-4 h-4" />
</Button>
{showMenu && (
<div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
<button
className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
onClick={() => {
connectMutation.mutate();
setShowMenu(false);
}}
>
<RefreshCw className="w-4 h-4" />
Reconnect
</button>
<button
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark-tertiary flex items-center gap-2"
onClick={() => {
setShowDeleteConfirm(true);
setShowMenu(false);
}}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
</div>
</div>
{/* Delete Confirmation */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Printer"
message={`Are you sure you want to delete "${printer.name}"? This will also remove all connection settings.`}
confirmText="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate();
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
{/* Status */}
{status?.connected && (
<>
{/* Printer State */}
<div className="mb-4">
<p className="text-sm text-bambu-gray mb-1">Status</p>
<p className="text-white font-medium capitalize">
{status.state?.toLowerCase() || 'Idle'}
</p>
</div>
{/* Current Print */}
{status.current_print && status.state === 'RUNNING' && (
<div className="mb-4 p-3 bg-bambu-dark rounded-lg">
<div className="flex gap-3">
{/* Cover Image */}
<CoverImage url={status.cover_url} printName={status.subtask_name || status.current_print || undefined} />
{/* Print Info */}
<div className="flex-1 min-w-0">
<p className="text-sm text-bambu-gray mb-1">Printing</p>
<p className="text-white text-sm mb-2 truncate">
{status.subtask_name || status.current_print}
</p>
<div className="flex items-center justify-between text-sm">
<div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
<div
className="bg-bambu-green h-2 rounded-full transition-all"
style={{ width: `${status.progress || 0}%` }}
/>
</div>
<span className="text-white">{Math.round(status.progress || 0)}%</span>
</div>
<div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
{status.remaining_time != null && status.remaining_time > 0 && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(status.remaining_time * 60)}
</span>
)}
{status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
<span>
Layer {status.layer_num}/{status.total_layers}
</span>
)}
</div>
</div>
</div>
</div>
)}
{/* Temperatures */}
{status.temperatures && (
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<Thermometer className="w-4 h-4 mx-auto mb-1 text-orange-400" />
<p className="text-xs text-bambu-gray">Nozzle</p>
<p className="text-sm text-white">
{Math.round(status.temperatures.nozzle || 0)}°C
</p>
</div>
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<Thermometer className="w-4 h-4 mx-auto mb-1 text-blue-400" />
<p className="text-xs text-bambu-gray">Bed</p>
<p className="text-sm text-white">
{Math.round(status.temperatures.bed || 0)}°C
</p>
</div>
{status.temperatures.chamber !== undefined && (
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<Thermometer className="w-4 h-4 mx-auto mb-1 text-green-400" />
<p className="text-xs text-bambu-gray">Chamber</p>
<p className="text-sm text-white">
{Math.round(status.temperatures.chamber || 0)}°C
</p>
</div>
)}
</div>
)}
</>
)}
{/* Connection Info & Actions */}
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
<div className="text-xs text-bambu-gray">
<p>{printer.ip_address}</p>
<p className="truncate">{printer.serial_number}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowFileManager(true)}
title="Browse printer files"
>
<HardDrive className="w-4 h-4" />
Files
</Button>
</div>
</CardContent>
{/* File Manager Modal */}
{showFileManager && (
<FileManagerModal
printerId={printer.id}
printerName={printer.name}
onClose={() => setShowFileManager(false)}
/>
)}
</Card>
);
}
function AddPrinterModal({
onClose,
onAdd,
}: {
onClose: () => void;
onAdd: (data: PrinterCreate) => void;
}) {
const [form, setForm] = useState<PrinterCreate>({
name: '',
serial_number: '',
ip_address: '',
access_code: '',
model: '',
auto_archive: true,
});
// Close on 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/50 flex items-center justify-center z-50"
onClick={onClose}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent>
<h2 className="text-xl font-semibold mb-4">Add Printer</h2>
<form
onSubmit={(e) => {
e.preventDefault();
onAdd(form);
}}
className="space-y-4"
>
<div>
<label className="block text-sm text-bambu-gray mb-1">Name</label>
<input
type="text"
required
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"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="My Printer"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">IP Address</label>
<input
type="text"
required
pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
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"
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
placeholder="192.168.1.100"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Serial Number</label>
<input
type="text"
required
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"
value={form.serial_number}
onChange={(e) => setForm({ ...form, serial_number: e.target.value })}
placeholder="01P00A000000000"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Access Code</label>
<input
type="password"
required
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"
value={form.access_code}
onChange={(e) => setForm({ ...form, access_code: e.target.value })}
placeholder="From printer settings"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Model (optional)</label>
<select
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"
value={form.model || ''}
onChange={(e) => setForm({ ...form, model: e.target.value })}
>
<option value="">Select model...</option>
<optgroup label="H2 Series">
<option value="H2C">H2C</option>
<option value="H2D">H2D</option>
<option value="H2S">H2S</option>
</optgroup>
<optgroup label="X1 Series">
<option value="X1E">X1E</option>
<option value="X1C">X1 Carbon</option>
<option value="X1">X1</option>
</optgroup>
<optgroup label="P Series">
<option value="P2S">P2S</option>
<option value="P1S">P1S</option>
<option value="P1P">P1P</option>
</optgroup>
<optgroup label="A1 Series">
<option value="A1">A1</option>
<option value="A1 Mini">A1 Mini</option>
</optgroup>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="auto_archive"
checked={form.auto_archive}
onChange={(e) => setForm({ ...form, auto_archive: e.target.checked })}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="auto_archive" className="text-sm text-bambu-gray">
Auto-archive completed prints
</label>
</div>
<div className="flex gap-3 pt-4">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1">
Add Printer
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
export function PrintersPage() {
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { data: printers, isLoading } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const addMutation = useMutation({
mutationFn: api.createPrinter,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printers'] });
setShowAddModal(false);
},
});
return (
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Printers</h1>
<p className="text-bambu-gray">Manage your Bambu Lab printers</p>
</div>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Add Printer
</Button>
</div>
{isLoading ? (
<div className="text-center py-12 text-bambu-gray">Loading printers...</div>
) : printers?.length === 0 ? (
<Card>
<CardContent className="text-center py-12">
<p className="text-bambu-gray mb-4">No printers configured yet</p>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Add Your First Printer
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{printers?.map((printer) => (
<PrinterCard key={printer.id} printer={printer} />
))}
</div>
)}
{showAddModal && (
<AddPrinterModal
onClose={() => setShowAddModal(false)}
onAdd={(data) => addMutation.mutate(data)}
/>
)}
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Save, RotateCcw, Loader2, Check } from 'lucide-react';
import { api } from '../api/client';
import type { AppSettings } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { useState, useEffect } from 'react';
export function SettingsPage() {
const queryClient = useQueryClient();
const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
const [hasChanges, setHasChanges] = useState(false);
const [showSaved, setShowSaved] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const { data: settings, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
// Sync local state when settings load
useEffect(() => {
if (settings && !localSettings) {
setLocalSettings(settings);
}
}, [settings, localSettings]);
// Track changes
useEffect(() => {
if (settings && localSettings) {
const changed =
settings.auto_archive !== localSettings.auto_archive ||
settings.save_thumbnails !== localSettings.save_thumbnails ||
settings.default_filament_cost !== localSettings.default_filament_cost ||
settings.currency !== localSettings.currency;
setHasChanges(changed);
}
}, [settings, localSettings]);
const updateMutation = useMutation({
mutationFn: api.updateSettings,
onSuccess: (data) => {
queryClient.setQueryData(['settings'], data);
setLocalSettings(data);
setHasChanges(false);
setShowSaved(true);
setTimeout(() => setShowSaved(false), 2000);
},
});
const resetMutation = useMutation({
mutationFn: api.resetSettings,
onSuccess: (data) => {
queryClient.setQueryData(['settings'], data);
setLocalSettings(data);
setHasChanges(false);
},
});
const handleSave = () => {
if (localSettings) {
updateMutation.mutate(localSettings);
}
};
const handleReset = () => {
setShowResetConfirm(true);
};
const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
if (localSettings) {
setLocalSettings({ ...localSettings, [key]: value });
}
};
if (isLoading || !localSettings) {
return (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-bambu-gray">Configure Bambusy</p>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={handleReset}
disabled={resetMutation.isPending}
>
<RotateCcw className="w-4 h-4" />
Reset
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : showSaved ? (
<Check className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
{showSaved ? 'Saved!' : 'Save'}
</Button>
</div>
</div>
{updateMutation.isError && (
<div className="mb-6 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
Failed to save settings: {(updateMutation.error as Error).message}
</div>
)}
<div className="space-y-6 max-w-2xl">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Archive Settings</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white">Auto-archive prints</p>
<p className="text-sm text-bambu-gray">
Automatically save 3MF files when prints complete
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.auto_archive}
onChange={(e) => updateSetting('auto_archive', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Save thumbnails</p>
<p className="text-sm text-bambu-gray">
Extract and save preview images from 3MF files
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.save_thumbnails}
onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">
Default filament cost (per kg)
</label>
<input
type="number"
step="0.01"
min="0"
value={localSettings.default_filament_cost}
onChange={(e) =>
updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
}
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"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Currency</label>
<select
value={localSettings.currency}
onChange={(e) => updateSetting('currency', 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="USD">USD ($)</option>
<option value="EUR">EUR (€)</option>
<option value="GBP">GBP (£)</option>
<option value="CHF">CHF (Fr.)</option>
<option value="JPY">JPY (¥)</option>
<option value="CNY">CNY (¥)</option>
<option value="CAD">CAD ($)</option>
<option value="AUD">AUD ($)</option>
</select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">About</h2>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p className="text-white">Bambusy v0.1.0</p>
<p className="text-bambu-gray">
Archive and manage your Bambu Lab 3MF files
</p>
<p className="text-bambu-gray">
Connect to printers via LAN mode (developer mode required)
</p>
</div>
</CardContent>
</Card>
</div>
{/* Reset Confirmation Modal */}
{showResetConfirm && (
<ConfirmModal
title="Reset Settings"
message="Reset all settings to defaults? This cannot be undone."
confirmText="Reset"
variant="danger"
onConfirm={() => {
resetMutation.mutate();
setShowResetConfirm(false);
}}
onCancel={() => setShowResetConfirm(false)}
/>
)}
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
import { useQuery } from '@tanstack/react-query';
import {
Package,
Clock,
CheckCircle,
XCircle,
DollarSign,
Printer,
} from 'lucide-react';
import { api } from '../api/client';
import { PrintCalendar } from '../components/PrintCalendar';
import { FilamentTrends } from '../components/FilamentTrends';
import { Dashboard, type DashboardWidget } from '../components/Dashboard';
// Widget Components
function QuickStatsWidget({
stats,
currency,
}: {
stats: {
total_prints: number;
successful_prints: number;
failed_prints: number;
total_print_time_hours: number;
total_filament_grams: number;
total_cost: number;
} | undefined;
currency: string;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
<Package className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Total Prints</p>
<p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-bambu-dark text-blue-400">
<Clock className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Print Time</p>
<p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-bambu-dark text-orange-400">
<Package className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Filament Used</p>
<p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-bambu-dark text-green-400">
<DollarSign className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Total Cost</p>
<p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
</div>
</div>
</div>
);
}
function SuccessRateWidget({
stats,
}: {
stats: {
total_prints: number;
successful_prints: number;
failed_prints: number;
} | undefined;
}) {
const successRate = stats?.total_prints
? Math.round((stats.successful_prints / stats.total_prints) * 100)
: 0;
return (
<div className="flex items-center gap-6">
<div className="relative w-28 h-28">
<svg className="w-full h-full -rotate-90">
<circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
<circle
cx="56"
cy="56"
r="48"
fill="none"
stroke="#00ae42"
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={`${successRate * 3.02} 302`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold text-white">{successRate}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green" />
<span className="text-sm text-bambu-gray">Successful:</span>
<span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
</div>
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-400" />
<span className="text-sm text-bambu-gray">Failed:</span>
<span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
</div>
</div>
</div>
);
}
function FilamentTypesWidget({
stats,
}: {
stats: {
total_prints: number;
prints_by_filament_type: Record<string, number>;
} | undefined;
}) {
if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
}
// Sort by print count descending
const sortedEntries = Object.entries(stats.prints_by_filament_type).sort(
([, a], [, b]) => b - a
);
return (
<div className="space-y-3">
{sortedEntries.map(([type, count]) => {
const percentage = Math.round((count / (stats.total_prints || 1)) * 100);
return (
<div key={type}>
<div className="flex justify-between text-sm mb-1">
<span className="text-white">{type}</span>
<span className="text-bambu-gray">{count} prints</span>
</div>
<div className="h-2 bg-bambu-dark rounded-full">
<div
className="h-full bg-bambu-green rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
);
}
function PrintActivityWidget({ printDates }: { printDates: string[] }) {
return <PrintCalendar printDates={printDates} months={4} />;
}
function PrintsByPrinterWidget({
stats,
printerMap,
}: {
stats: { prints_by_printer: Record<string, number> } | undefined;
printerMap: Map<string, string>;
}) {
if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
return <p className="text-bambu-gray text-center py-4">No printer data available</p>;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
<div key={printerId} className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
<div className="p-2 bg-bambu-dark-tertiary rounded-lg">
<Printer className="w-4 h-4 text-bambu-green" />
</div>
<div>
<p className="text-white font-medium text-sm">
{printerMap.get(printerId) || `Printer ${printerId}`}
</p>
<p className="text-xs text-bambu-gray">{count} prints</p>
</div>
</div>
))}
</div>
);
}
function FilamentTrendsWidget({
archives,
currency,
}: {
archives: Parameters<typeof FilamentTrends>[0]['archives'];
currency: string;
}) {
if (!archives || archives.length === 0) {
return <p className="text-bambu-gray text-center py-4">No print data available</p>;
}
return <FilamentTrends archives={archives} currency={currency} />;
}
export function StatsPage() {
const { data: stats, isLoading } = useQuery({
queryKey: ['archiveStats'],
queryFn: api.getArchiveStats,
});
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const { data: archives } = useQuery({
queryKey: ['archives'],
queryFn: () => api.getArchives(undefined, 1000, 0),
});
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const currency = settings?.currency || '$';
const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
const printDates = archives?.map((a) => a.created_at) || [];
if (isLoading) {
return (
<div className="p-8">
<div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
</div>
);
}
// Define dashboard widgets
// Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width
const widgets: DashboardWidget[] = [
{
id: 'quick-stats',
title: 'Quick Stats',
component: <QuickStatsWidget stats={stats} currency={currency} />,
defaultSize: 2,
},
{
id: 'success-rate',
title: 'Success Rate',
component: <SuccessRateWidget stats={stats} />,
defaultSize: 1,
},
{
id: 'filament-types',
title: 'Filament Types',
component: <FilamentTypesWidget stats={stats} />,
defaultSize: 1,
},
{
id: 'print-activity',
title: 'Print Activity',
component: <PrintActivityWidget printDates={printDates} />,
defaultSize: 2,
},
{
id: 'prints-by-printer',
title: 'Prints by Printer',
component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} />,
defaultSize: 2,
},
{
id: 'filament-trends',
title: 'Filament Usage Trends',
component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
defaultSize: 4,
},
];
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
</div>
<Dashboard widgets={widgets} storageKey="bambusy-dashboard-layout" />
</div>
);
}