mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Added screenshots
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user