Added screenshots

This commit is contained in:
Martin Ziegler
2025-11-28 10:23:59 +01:00
parent 8a85147713
commit 09677861ba
94 changed files with 25918 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Tag, Plus, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface BatchTagModalProps {
selectedIds: number[];
existingTags: string[];
onClose: () => void;
}
export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [newTag, setNewTag] = useState('');
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [mode, setMode] = useState<'add' | 'remove'>('add');
const batchTagMutation = useMutation({
mutationFn: async () => {
const tagsArray = Array.from(selectedTags);
await Promise.all(
selectedIds.map(async (id) => {
const archive = await api.getArchive(id);
const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
let newTags: string[];
if (mode === 'add') {
// Add tags that aren't already present
newTags = [...new Set([...currentTags, ...tagsArray])];
} else {
// Remove selected tags
newTags = currentTags.filter(t => !selectedTags.has(t));
}
return api.updateArchive(id, { tags: newTags.join(', ') });
})
);
return { count: selectedIds.length, mode, tags: tagsArray };
},
onSuccess: ({ count, mode, tags }) => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
onClose();
},
onError: () => {
showToast('Failed to update tags', 'error');
},
});
const toggleTag = (tag: string) => {
setSelectedTags((prev) => {
const next = new Set(prev);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
return next;
});
};
const addNewTag = () => {
if (newTag.trim() && !selectedTags.has(newTag.trim())) {
setSelectedTags((prev) => new Set([...prev, newTag.trim()]));
setNewTag('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag();
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">
{mode === 'add' ? 'Add Tags' : 'Remove Tags'}
</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
<p className="text-sm text-bambu-gray">
{mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''}
</p>
{/* Mode toggle */}
<div className="flex gap-2">
<Button
size="sm"
variant={mode === 'add' ? 'primary' : 'secondary'}
onClick={() => setMode('add')}
>
Add Tags
</Button>
<Button
size="sm"
variant={mode === 'remove' ? 'primary' : 'secondary'}
onClick={() => setMode('remove')}
>
Remove Tags
</Button>
</div>
{/* New tag input (only for add mode) */}
{mode === 'add' && (
<div className="flex gap-2">
<input
type="text"
placeholder="Enter new tag..."
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button size="sm" variant="secondary" onClick={addNewTag} disabled={!newTag.trim()}>
<Plus className="w-4 h-4" />
</Button>
</div>
)}
{/* Existing tags */}
{existingTags.length > 0 && (
<div>
<p className="text-xs text-bambu-gray mb-2">Existing tags:</p>
<div className="flex flex-wrap gap-2">
{existingTags.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-2 py-1 rounded text-sm transition-colors ${
selectedTags.has(tag)
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray-light hover:bg-bambu-dark'
}`}
>
{tag}
</button>
))}
</div>
</div>
)}
{/* Selected tags preview */}
{selectedTags.size > 0 && (
<div>
<p className="text-xs text-bambu-gray mb-2">
Tags to {mode === 'add' ? 'add' : 'remove'}:
</p>
<div className="flex flex-wrap gap-2">
{Array.from(selectedTags).map((tag) => (
<span
key={tag}
className={`px-2 py-1 rounded text-sm flex items-center gap-1 ${
mode === 'add' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}
>
{tag}
<button onClick={() => toggleTag(tag)} className="hover:opacity-70">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
onClick={() => batchTagMutation.mutate()}
disabled={selectedTags.size === 0 || batchTagMutation.isPending}
className="flex-1"
>
{batchTagMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : (
<>
<Tag className="w-4 h-4" />
{mode === 'add' ? 'Add Tags' : 'Remove Tags'}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}: ButtonProps) {
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green',
secondary:
'bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
ghost:
'bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-6 py-3 text-base gap-2',
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { useState, useMemo } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { Archive } from '../api/client';
import { api } from '../api/client';
interface CalendarViewProps {
archives: Archive[];
onArchiveClick?: (archive: Archive) => void;
}
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
const today = new Date();
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// Group archives by date
const archivesByDate = useMemo(() => {
const map = new Map<string, Archive[]>();
archives.forEach(archive => {
const date = new Date(archive.completed_at || archive.created_at);
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const existing = map.get(key) || [];
existing.push(archive);
map.set(key, existing);
});
return map;
}, [archives]);
const daysInMonth = getDaysInMonth(currentYear, currentMonth);
const firstDay = getFirstDayOfMonth(currentYear, currentMonth);
const prevMonth = () => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
};
const nextMonth = () => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
};
const goToToday = () => {
setCurrentMonth(today.getMonth());
setCurrentYear(today.getFullYear());
};
// Build calendar grid
const calendarDays: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) {
calendarDays.push(null);
}
for (let day = 1; day <= daysInMonth; day++) {
calendarDays.push(day);
}
const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
return (
<div className="flex flex-col lg:flex-row gap-6">
{/* Calendar */}
<div className="flex-1">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<button
onClick={prevMonth}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5 text-bambu-gray" />
</button>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-white">
{MONTH_NAMES[currentMonth]} {currentYear}
</h2>
<button
onClick={goToToday}
className="px-2 py-1 text-xs bg-bambu-dark-tertiary hover:bg-bambu-green/20 text-bambu-gray hover:text-white rounded transition-colors"
>
Today
</button>
</div>
<button
onClick={nextMonth}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
>
<ChevronRight className="w-5 h-5 text-bambu-gray" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{DAY_NAMES.map(day => (
<div key={day} className="text-center text-xs text-bambu-gray py-2">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="aspect-square" />;
}
const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dayArchives = archivesByDate.get(dateKey) || [];
const hasArchives = dayArchives.length > 0;
const isToday = day === today.getDate() && currentMonth === today.getMonth() && currentYear === today.getFullYear();
const isSelected = dateKey === selectedDate;
const successCount = dayArchives.filter(a => a.status === 'completed').length;
const failedCount = dayArchives.filter(a => a.status === 'failed').length;
return (
<button
key={day}
onClick={() => setSelectedDate(isSelected ? null : dateKey)}
className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${
isSelected
? 'bg-bambu-green text-white'
: isToday
? 'bg-bambu-green/20 text-white ring-2 ring-bambu-green'
: hasArchives
? 'bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 text-white'
: 'hover:bg-bambu-dark-tertiary/50 text-bambu-gray'
}`}
>
<span className={`text-sm font-medium ${isToday && !isSelected ? 'text-bambu-green' : ''}`}>
{day}
</span>
{hasArchives && (
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex items-center gap-1">
<div className={`w-2 h-2 rounded-full ${
failedCount > 0 && successCount === 0
? 'bg-red-400'
: failedCount > 0
? 'bg-yellow-400'
: 'bg-green-400'
}`} />
<span className="text-xs font-medium">{dayArchives.length}</span>
</div>
)}
</button>
);
})}
</div>
{/* Monthly stats */}
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-white">
{archives.filter(a => {
const d = new Date(a.completed_at || a.created_at);
return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
}).length}
</div>
<div className="text-xs text-bambu-gray">Prints this month</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">
{archives.filter(a => {
const d = new Date(a.completed_at || a.created_at);
return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed';
}).length}
</div>
<div className="text-xs text-bambu-gray">Successful</div>
</div>
<div>
<div className="text-2xl font-bold text-red-400">
{archives.filter(a => {
const d = new Date(a.completed_at || a.created_at);
return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed';
}).length}
</div>
<div className="text-xs text-bambu-gray">Failed</div>
</div>
</div>
</div>
</div>
{/* Selected day details */}
<div className="lg:w-80 bg-bambu-dark rounded-xl p-4">
{selectedDate ? (
<>
<h3 className="text-sm font-medium text-bambu-gray mb-3">
{new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</h3>
{selectedArchives.length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto">
{selectedArchives.map(archive => (
<button
key={archive.id}
onClick={() => onArchiveClick?.(archive)}
className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-left"
>
{archive.thumbnail_path ? (
<img
src={api.getArchiveThumbnail(archive.id)}
alt=""
className="w-12 h-12 rounded object-cover"
/>
) : (
<div className="w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<span className="text-xs text-bambu-gray">3MF</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">
{archive.print_name || archive.filename}
</p>
<div className="flex items-center gap-2 text-xs">
<span className={archive.status === 'failed' ? 'text-red-400' : 'text-green-400'}>
{archive.status === 'failed' ? 'Failed' : 'Completed'}
</span>
{archive.filament_color && (
<div className="flex gap-0.5">
{archive.filament_color.split(',').map((color, i) => (
<div
key={i}
className="w-3 h-3 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
))}
</div>
)}
</div>
</div>
</button>
))}
</div>
) : (
<p className="text-sm text-bambu-gray">No prints on this day</p>
)}
</>
) : (
<div className="text-center py-8">
<p className="text-sm text-bambu-gray">Select a day to see prints</p>
</div>
)}
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode, MouseEvent } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
onClick?: (e: MouseEvent) => void;
onContextMenu?: (e: MouseEvent) => void;
}
export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
return (
<div
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary ${className}`}
onClick={onClick}
onContextMenu={onContextMenu}
>
{children}
</div>
);
}
export function CardHeader({ children, className = '' }: CardProps) {
return (
<div className={`px-6 py-4 border-b border-bambu-dark-tertiary ${className}`}>
{children}
</div>
);
}
export function CardContent({ children, className = '' }: CardProps) {
return <div className={`p-6 ${className}`}>{children}</div>;
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
interface ConfirmModalProps {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'default';
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmModal({
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'default',
onConfirm,
onCancel,
}: ConfirmModalProps) {
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCancel]);
const variantStyles = {
danger: {
icon: 'text-red-400',
button: 'bg-red-500 hover:bg-red-600',
},
warning: {
icon: 'text-yellow-400',
button: 'bg-yellow-500 hover:bg-yellow-600',
},
default: {
icon: 'text-bambu-green',
button: 'bg-bambu-green hover:bg-bambu-green-dark',
},
};
const styles = variantStyles[variant];
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onCancel}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
<AlertTriangle className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-bambu-gray text-sm">{message}</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<Button variant="secondary" onClick={onCancel} className="flex-1">
{cancelText}
</Button>
<Button
onClick={onConfirm}
className={`flex-1 ${styles.button}`}
>
{confirmText}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef } from 'react';
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
const handleScroll = () => {
onClose();
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('scroll', handleScroll, true);
};
}, [onClose]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + rect.width > viewportWidth) {
adjustedX = viewportWidth - rect.width - 8;
}
if (y + rect.height > viewportHeight) {
adjustedY = viewportHeight - rect.height - 8;
}
menuRef.current.style.left = `${adjustedX}px`;
menuRef.current.style.top = `${adjustedY}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-50 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden"
style={{ left: x, top: y }}
>
{items.map((item, index) => {
if (item.divider) {
return <div key={index} className="my-1 border-t border-bambu-dark-tertiary" />;
}
return (
<button
key={index}
onClick={() => {
if (!item.disabled) {
item.onClick();
onClose();
}
}}
disabled={item.disabled}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
item.disabled
? 'text-bambu-gray cursor-not-allowed'
: item.danger
? 'text-red-400 hover:bg-red-400/10'
: 'text-white hover:bg-bambu-dark-tertiary'
}`}
>
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
{item.label}
</button>
);
})}
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useState, useEffect, type ReactNode } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Eye, EyeOff, RotateCcw, Maximize2, Minimize2 } from 'lucide-react';
import { Button } from './Button';
export interface DashboardWidget {
id: string;
title: string;
component: ReactNode;
defaultVisible?: boolean;
defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default)
}
interface DashboardProps {
widgets: DashboardWidget[];
storageKey: string;
columns?: number;
}
interface LayoutState {
order: string[];
hidden: string[];
sizes: Record<string, 1 | 2 | 4>;
}
function SortableWidget({
id,
title,
children,
isHidden,
size,
onToggleVisibility,
onToggleSize,
}: {
id: string;
title: string;
children: ReactNode;
isHidden: boolean;
size: 1 | 2 | 4;
onToggleVisibility: () => void;
onToggleSize: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
if (isHidden) return null;
return (
<div
ref={setNodeRef}
style={{
...style,
gridColumn: `span ${size}`,
}}
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden ${
isDragging ? 'ring-2 ring-bambu-green shadow-lg' : ''
}`}
>
{/* Widget Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/30">
<div className="flex items-center gap-2">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
</button>
<h3 className="text-sm font-medium text-white">{title}</h3>
</div>
<div className="flex items-center gap-1">
<button
onClick={onToggleSize}
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
title={`Size: ${size === 1 ? '1/4' : size === 2 ? '1/2' : 'Full'} - Click to cycle`}
>
{size === 4 ? (
<Minimize2 className="w-4 h-4 text-bambu-gray hover:text-white" />
) : (
<Maximize2 className="w-4 h-4 text-bambu-gray hover:text-white" />
)}
</button>
<button
onClick={onToggleVisibility}
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
title="Hide widget"
>
<EyeOff className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
</div>
</div>
{/* Widget Content */}
<div className="p-4">{children}</div>
</div>
);
}
export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) {
// Build default sizes from widget definitions
const getDefaultSizes = () => {
const sizes: Record<string, 1 | 2 | 4> = {};
widgets.forEach((w) => {
sizes[w.id] = w.defaultSize || 4;
});
return sizes;
};
const [layout, setLayout] = useState<LayoutState>(() => {
// Load saved layout from localStorage
const saved = localStorage.getItem(storageKey);
if (saved) {
try {
const parsed = JSON.parse(saved);
// Ensure sizes exist (for backwards compatibility)
if (!parsed.sizes) {
parsed.sizes = getDefaultSizes();
}
return parsed;
} catch {
// Invalid JSON, use default
}
}
// Default layout: all widgets visible in original order
return {
order: widgets.map((w) => w.id),
hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
sizes: getDefaultSizes(),
};
});
const [showHiddenPanel, setShowHiddenPanel] = useState(false);
// Save layout to localStorage whenever it changes
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(layout));
}, [layout, storageKey]);
// Ensure all widget IDs are in the order array (for newly added widgets)
useEffect(() => {
const allIds = widgets.map((w) => w.id);
const missingIds = allIds.filter((id) => !layout.order.includes(id));
if (missingIds.length > 0) {
setLayout((prev) => ({
...prev,
order: [...prev.order, ...missingIds],
}));
}
}, [widgets, layout.order]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setLayout((prev) => {
const oldIndex = prev.order.indexOf(active.id as string);
const newIndex = prev.order.indexOf(over.id as string);
return {
...prev,
order: arrayMove(prev.order, oldIndex, newIndex),
};
});
}
};
const toggleVisibility = (id: string) => {
setLayout((prev) => ({
...prev,
hidden: prev.hidden.includes(id)
? prev.hidden.filter((h) => h !== id)
: [...prev.hidden, id],
}));
};
const toggleSize = (id: string) => {
setLayout((prev) => {
const currentSize = prev.sizes[id] || 4;
// Cycle: 1 → 2 → 4 → 1
const nextSize = currentSize === 1 ? 2 : currentSize === 2 ? 4 : 1;
return {
...prev,
sizes: {
...prev.sizes,
[id]: nextSize as 1 | 2 | 4,
},
};
});
};
const resetLayout = () => {
const defaultLayout = {
order: widgets.map((w) => w.id),
hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),
sizes: getDefaultSizes(),
};
setLayout(defaultLayout);
};
// Get ordered widgets
const orderedWidgets = layout.order
.map((id) => widgets.find((w) => w.id === id))
.filter(Boolean) as DashboardWidget[];
const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id));
const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id));
return (
<div className="space-y-4">
{/* Dashboard Controls */}
<div className="flex items-center justify-end gap-2">
{hiddenWidgets.length > 0 && (
<Button
variant="secondary"
size="sm"
onClick={() => setShowHiddenPanel(!showHiddenPanel)}
>
<Eye className="w-4 h-4" />
{hiddenWidgets.length} Hidden
</Button>
)}
<Button variant="secondary" size="sm" onClick={resetLayout}>
<RotateCcw className="w-4 h-4" />
Reset Layout
</Button>
</div>
{/* Hidden Widgets Panel */}
{showHiddenPanel && hiddenWidgets.length > 0 && (
<div className="p-4 bg-bambu-dark rounded-xl border border-bambu-dark-tertiary">
<p className="text-sm text-bambu-gray mb-3">Hidden widgets (click to show):</p>
<div className="flex flex-wrap gap-2">
{hiddenWidgets.map((widget) => (
<button
key={widget.id}
onClick={() => toggleVisibility(widget.id)}
className="px-3 py-1.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded-lg text-sm text-white transition-colors flex items-center gap-2"
>
<Eye className="w-3 h-3" />
{widget.title}
</button>
))}
</div>
</div>
)}
{/* Draggable Widgets Grid */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={visibleWidgets.map((w) => w.id)} strategy={rectSortingStrategy}>
<div
className="grid gap-6"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{visibleWidgets.map((widget) => (
<SortableWidget
key={widget.id}
id={widget.id}
title={widget.title}
isHidden={layout.hidden.includes(widget.id)}
size={layout.sizes[widget.id] || 2}
onToggleVisibility={() => toggleVisibility(widget.id)}
onToggleSize={() => toggleSize(widget.id)}
>
{widget.component}
</SortableWidget>
))}
</div>
</SortableContext>
</DndContext>
{visibleWidgets.length === 0 && (
<div className="text-center py-12 text-bambu-gray">
<p>All widgets are hidden.</p>
<Button className="mt-4" onClick={resetLayout}>
Reset Layout
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,358 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Tag, Camera, Trash2, Loader2, Plus } from 'lucide-react';
import { api } from '../api/client';
import type { Archive } from '../api/client';
import { Button } from './Button';
const FAILURE_REASONS = [
'Adhesion failure',
'Spaghetti / Detached',
'Layer shift',
'Clogged nozzle',
'Filament runout',
'Warping',
'Stringing',
'Under-extrusion',
'Power failure',
'User cancelled',
'Other',
];
interface EditArchiveModalProps {
archive: Archive;
onClose: () => void;
existingTags?: string[];
}
export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const queryClient = useQueryClient();
const [printName, setPrintName] = useState(archive.print_name || '');
const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
const [notes, setNotes] = useState(archive.notes || '');
const [tags, setTags] = useState(archive.tags || '');
const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
const [photos, setPhotos] = useState<string[]>(archive.photos || []);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
const tagInputRef = useRef<HTMLInputElement>(null);
const photoInputRef = useRef<HTMLInputElement>(null);
const blurTimeoutRef = useRef<number | null>(null);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Get all archives to extract existing tags if not provided
const { data: archives } = useQuery({
queryKey: ['archives'],
queryFn: () => api.getArchives(undefined, 1000, 0),
enabled: existingTags.length === 0,
});
// Extract unique tags from all archives
const allTags = existingTags.length > 0
? existingTags
: [...new Set(
archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
)].sort();
// Get current tags as array
const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
// Filter suggestions based on what's not already added
const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
// Add a tag
const addTag = (tag: string) => {
if (!currentTags.includes(tag)) {
const newTags = [...currentTags, tag].join(', ');
setTags(newTags);
}
// Clear any pending blur timeout to prevent hiding suggestions
if (blurTimeoutRef.current !== null) {
clearTimeout(blurTimeoutRef.current);
}
tagInputRef.current?.focus();
};
// Remove a tag
const removeTag = (tagToRemove: string) => {
const newTags = currentTags.filter(t => t !== tagToRemove).join(', ');
setTags(newTags);
};
const updateMutation = useMutation({
mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
api.updateArchive(archive.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
onClose();
},
});
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingPhoto(true);
try {
const result = await api.uploadArchivePhoto(archive.id, file);
setPhotos(result.photos);
queryClient.invalidateQueries({ queryKey: ['archives'] });
} catch (error) {
console.error('Failed to upload photo:', error);
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) {
photoInputRef.current.value = '';
}
}
};
const handlePhotoDelete = async (filename: string) => {
try {
const result = await api.deleteArchivePhoto(archive.id, filename);
setPhotos(result.photos || []);
queryClient.invalidateQueries({ queryKey: ['archives'] });
} catch (error) {
console.error('Failed to delete photo:', error);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate({
print_name: printName || undefined,
printer_id: printerId,
notes: notes || undefined,
tags: tags || undefined,
failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined,
});
};
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">Edit Archive</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{/* Print Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Name</label>
<input
type="text"
value={printName}
onChange={(e) => setPrintName(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
placeholder="Print name"
/>
</div>
{/* Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">No printer</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none resize-none"
placeholder="Add notes about this print..."
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
{/* Current tags as chips */}
{currentTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white"
>
<Tag className="w-3 h-3" />
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-bambu-gray hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Tag input with suggestions */}
<div className="relative">
<input
ref={tagInputRef}
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
onFocus={() => {
if (blurTimeoutRef.current !== null) {
clearTimeout(blurTimeoutRef.current);
}
setShowTagSuggestions(true);
}}
onBlur={() => {
blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
placeholder={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
/>
{/* Suggestions dropdown */}
{showTagSuggestions && tagSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
Existing tags (click to add)
</div>
<div className="p-2 flex flex-wrap gap-1.5">
{tagSuggestions.map((tag) => (
<button
key={tag}
type="button"
onClick={() => addTag(tag)}
className="px-2 py-0.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded text-sm text-bambu-gray hover:text-white transition-colors"
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Failure Reason - only show for failed prints */}
{archive.status === 'failed' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
<select
value={failureReason}
onChange={(e) => setFailureReason(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">Select reason...</option>
{FAILURE_REASONS.map((reason) => (
<option key={reason} value={reason}>
{reason}
</option>
))}
</select>
</div>
)}
{/* Photos */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Camera className="w-4 h-4 inline mr-1" />
Photos of Printed Result
</label>
{/* Photo grid */}
<div className="flex flex-wrap gap-2 mb-2">
{photos.map((filename) => (
<div key={filename} className="relative group">
<img
src={api.getArchivePhotoUrl(archive.id, filename)}
alt="Print result"
className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
/>
<button
type="button"
onClick={() => handlePhotoDelete(filename)}
className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3 text-white" />
</button>
</div>
))}
{/* Upload button */}
<label className="w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors">
<input
ref={photoInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handlePhotoUpload}
className="hidden"
disabled={uploadingPhoto}
/>
{uploadingPhoto ? (
<Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
) : (
<Plus className="w-6 h-6 text-bambu-gray" />
)}
</label>
</div>
<p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={updateMutation.isPending}
className="flex-1"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</div>
</form>
</div>
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
import { useMemo, useState } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import type { Archive } from '../api/client';
interface FilamentTrendsProps {
archives: Archive[];
currency?: string;
}
type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
function getDateRange(range: TimeRange): Date {
const now = new Date();
switch (range) {
case '7d':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case '30d':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case '90d':
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
case '365d':
return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
case 'all':
return new Date(0);
}
}
export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps) {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
// Filter archives by time range
const filteredArchives = useMemo(() => {
const startDate = getDateRange(timeRange);
return archives.filter(a => new Date(a.completed_at || a.created_at) >= startDate);
}, [archives, timeRange]);
// Calculate daily usage data
const dailyData = useMemo(() => {
const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
filteredArchives.forEach(archive => {
const date = new Date(archive.completed_at || archive.created_at);
const key = date.toISOString().split('T')[0];
const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
existing.filament += archive.filament_used_grams || 0;
existing.cost += archive.cost || 0;
existing.prints += 1;
dataMap.set(key, existing);
});
return Array.from(dataMap.values())
.sort((a, b) => a.date.localeCompare(b.date))
.map(d => ({
...d,
dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
}));
}, [filteredArchives]);
// Calculate weekly aggregated data for longer time ranges
const weeklyData = useMemo(() => {
if (timeRange === '7d' || timeRange === '30d') return dailyData;
const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
filteredArchives.forEach(archive => {
const date = new Date(archive.completed_at || archive.created_at);
// Get week start (Sunday)
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
const key = weekStart.toISOString().split('T')[0];
const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
existing.filament += archive.filament_used_grams || 0;
existing.cost += archive.cost || 0;
existing.prints += 1;
dataMap.set(key, existing);
});
return Array.from(dataMap.values())
.sort((a, b) => a.week.localeCompare(b.week))
.map(d => ({
date: d.week,
dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,
...d,
}));
}, [filteredArchives, dailyData, timeRange]);
// Usage by filament type
const filamentTypeData = useMemo(() => {
const dataMap = new Map<string, number>();
filteredArchives.forEach(archive => {
const type = archive.filament_type || 'Unknown';
// Handle multiple types (e.g., "PLA, PETG")
const types = type.split(', ');
types.forEach(t => {
const grams = (archive.filament_used_grams || 0) / types.length;
dataMap.set(t, (dataMap.get(t) || 0) + grams);
});
});
return Array.from(dataMap.entries())
.map(([name, value]) => ({ name, value: Math.round(value) }))
.sort((a, b) => b.value - a.value);
}, [filteredArchives]);
// Monthly comparison data
const monthlyComparison = useMemo(() => {
const now = new Date();
const months: { month: string; filament: number; cost: number; prints: number }[] = [];
for (let i = 5; i >= 0; i--) {
const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
const monthArchives = archives.filter(a => {
const d = new Date(a.completed_at || a.created_at);
return d >= monthDate && d <= monthEnd;
});
months.push({
month: monthStr,
filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
prints: monthArchives.length,
});
}
return months;
}, [archives]);
const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
return (
<div className="space-y-6">
{/* Time Range Selector */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
<div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
{(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
timeRange === range
? 'bg-bambu-green text-white'
: 'text-bambu-gray hover:text-white'
}`}
>
{range === 'all' ? 'All' : range.replace('d', 'D')}
</button>
))}
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-bambu-dark rounded-lg p-4">
<p className="text-sm text-bambu-gray">Period Filament</p>
<p className="text-2xl font-bold text-white">{(totalFilament / 1000).toFixed(2)}kg</p>
<p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
</div>
<div className="bg-bambu-dark rounded-lg p-4">
<p className="text-sm text-bambu-gray">Period Cost</p>
<p className="text-2xl font-bold text-white">{currency}{totalCost.toFixed(2)}</p>
<p className="text-xs text-bambu-gray">{filteredArchives.length} prints</p>
</div>
<div className="bg-bambu-dark rounded-lg p-4">
<p className="text-sm text-bambu-gray">Avg per Print</p>
<p className="text-2xl font-bold text-white">
{filteredArchives.length > 0
? (totalFilament / filteredArchives.length).toFixed(0)
: 0}g
</p>
<p className="text-xs text-bambu-gray">
{currency}{filteredArchives.length > 0 ? (totalCost / filteredArchives.length).toFixed(2) : '0.00'} avg
</p>
</div>
</div>
{/* Usage Over Time Chart */}
{chartData.length > 0 ? (
<div className="bg-bambu-dark rounded-lg p-4">
<h4 className="text-sm font-medium text-bambu-gray mb-4">Usage Over Time</h4>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorFilament" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#00ae42" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#00ae42" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
<XAxis
dataKey="dateLabel"
stroke="#9ca3af"
tick={{ fontSize: 12 }}
interval="preserveStartEnd"
/>
<YAxis
stroke="#9ca3af"
tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value}g`}
/>
<Tooltip
contentStyle={{
backgroundColor: '#2d2d2d',
border: '1px solid #3d3d3d',
borderRadius: '8px',
}}
labelStyle={{ color: '#fff' }}
formatter={(value: number) => [`${value.toFixed(0)}g`, 'Filament']}
/>
<Area
type="monotone"
dataKey="filament"
stroke="#00ae42"
strokeWidth={2}
fillOpacity={1}
fill="url(#colorFilament)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray">
No data for selected time range
</div>
)}
{/* Bottom Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Filament Type Distribution */}
<div className="bg-bambu-dark rounded-lg p-4">
<h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
{filamentTypeData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={filamentTypeData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
dataKey="value"
label={({ name, percent }) => `${name} ${((percent || 0) * 100).toFixed(0)}%`}
labelLine={false}
>
{filamentTypeData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#2d2d2d',
border: '1px solid #3d3d3d',
borderRadius: '8px',
}}
formatter={(value: number) => [`${value}g`, 'Usage']}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[200px] flex items-center justify-center text-bambu-gray">
No filament data
</div>
)}
</div>
{/* Monthly Comparison */}
<div className="bg-bambu-dark rounded-lg p-4">
<h4 className="text-sm font-medium text-bambu-gray mb-4">Monthly Comparison</h4>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={monthlyComparison}>
<CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 12 }} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}g`} />
<Tooltip
contentStyle={{
backgroundColor: '#2d2d2d',
border: '1px solid #3d3d3d',
borderRadius: '8px',
}}
formatter={(value: number, name: string) => [
name === 'filament' ? `${value}g` : name === 'cost' ? `${currency}${value.toFixed(2)}` : value,
name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
]}
/>
<Legend />
<Bar dataKey="filament" name="Filament (g)" fill="#00ae42" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
@@ -0,0 +1,336 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
X,
Folder,
File,
ChevronLeft,
Download,
Trash2,
Loader2,
HardDrive,
RefreshCw,
Film,
FileBox,
FileText,
Image,
Search,
} from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
interface FileManagerModalProps {
printerId: number;
printerName: string;
onClose: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatStorageSize(bytes: number): string {
if (bytes === 0) return '0 GB';
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) {
return `${gb.toFixed(1)} GB`;
}
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(0)} MB`;
}
function getFileIcon(filename: string, isDirectory: boolean) {
if (isDirectory) return Folder;
const ext = filename.toLowerCase().split('.').pop() || '';
switch (ext) {
case '3mf':
return FileBox;
case 'gcode':
return FileText;
case 'mp4':
case 'avi':
return Film;
case 'png':
case 'jpg':
case 'jpeg':
return Image;
default:
return File;
}
}
export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [currentPath, setCurrentPath] = useState('/');
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { data, isLoading, refetch } = useQuery({
queryKey: ['printerFiles', printerId, currentPath],
queryFn: () => api.getPrinterFiles(printerId, currentPath),
});
const { data: storageData } = useQuery({
queryKey: ['printerStorage', printerId],
queryFn: () => api.getPrinterStorage(printerId),
staleTime: 30000, // Cache for 30 seconds
});
const deleteMutation = useMutation({
mutationFn: (path: string) => api.deletePrinterFile(printerId, path),
onSuccess: (_, path) => {
showToast(`Deleted: ${path.split('/').pop()}`);
queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
setSelectedFile(null);
},
onError: (error: Error) => {
showToast(`Delete failed: ${error.message}`, 'error');
},
});
const navigateToFolder = (path: string) => {
setCurrentPath(path);
setSelectedFile(null);
};
const navigateUp = () => {
if (currentPath === '/') return;
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
setCurrentPath(parts.length ? '/' + parts.join('/') : '/');
setSelectedFile(null);
};
const handleDownload = (path: string) => {
window.open(api.getPrinterFileDownloadUrl(printerId, path), '_blank');
};
const handleDelete = (path: string) => {
setFileToDelete(path);
};
// Quick navigation buttons for common directories
const quickDirs = [
{ path: '/', label: 'Root' },
{ path: '/cache', label: 'Cache' },
{ path: '/model', label: 'Models' },
{ path: '/timelapse', label: 'Timelapse' },
];
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="w-full max-w-3xl max-h-[85vh] flex flex-col bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-bambu-green" />
<div>
<h2 className="text-lg font-semibold text-white">File Manager</h2>
<p className="text-sm text-bambu-gray">{printerName}</p>
</div>
</div>
<div className="flex items-center gap-4">
{/* Storage info */}
{storageData?.used_bytes != null && storageData.used_bytes > 0 && (
<div className="text-sm text-bambu-gray">
Used: {formatStorageSize(storageData.used_bytes)}
</div>
)}
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Quick Navigation */}
<div className="flex items-center gap-2 p-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
{quickDirs.map((dir) => (
<button
key={dir.path}
onClick={() => {
navigateToFolder(dir.path);
setSearchQuery('');
}}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
currentPath === dir.path
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
>
{dir.label}
</button>
))}
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Filter files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
/>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* Path breadcrumb */}
<div className="flex items-center gap-2 px-4 py-2 bg-bambu-dark text-sm flex-shrink-0">
<button
onClick={navigateUp}
disabled={currentPath === '/'}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-bambu-gray font-mono">{currentPath}</span>
</div>
{/* File list */}
<div className="flex-1 overflow-y-auto p-2 min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : !data?.files?.length ? (
<div className="text-center py-12 text-bambu-gray">
No files in this directory
</div>
) : (
<div className="space-y-1">
{/* Filter and sort: directories first, then files */}
{[...data.files]
.filter((file) =>
!searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())
)
.sort((a, b) => {
if (a.is_directory && !b.is_directory) return -1;
if (!a.is_directory && b.is_directory) return 1;
return a.name.localeCompare(b.name);
})
.map((file) => {
const FileIcon = getFileIcon(file.name, file.is_directory);
const isSelected = selectedFile === file.path;
return (
<div
key={file.path}
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
isSelected
? 'bg-bambu-green/20 border border-bambu-green/50'
: 'hover:bg-bambu-dark-tertiary'
}`}
onClick={() => {
if (file.is_directory) {
navigateToFolder(file.path);
} else {
setSelectedFile(isSelected ? null : file.path);
}
}}
>
<FileIcon
className={`w-5 h-5 flex-shrink-0 ${
file.is_directory ? 'text-bambu-green' : 'text-bambu-gray'
}`}
/>
<span className="flex-1 text-white truncate">{file.name}</span>
{!file.is_directory && (
<span className="text-sm text-bambu-gray">
{formatFileSize(file.size)}
</span>
)}
{file.is_directory && (
<ChevronLeft className="w-4 h-4 text-bambu-gray rotate-180" />
)}
</div>
);
})}
</div>
)}
</div>
{/* Action bar */}
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
<div className="text-sm text-bambu-gray">
{searchQuery
? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items`
: `${data?.files?.length || 0} items`
}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
disabled={!selectedFile}
onClick={() => selectedFile && handleDownload(selectedFile)}
>
<Download className="w-4 h-4" />
Download
</Button>
<Button
variant="secondary"
disabled={!selectedFile || deleteMutation.isPending}
onClick={() => selectedFile && handleDelete(selectedFile)}
className="text-red-400 hover:text-red-300"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{fileToDelete && (
<ConfirmModal
title="Delete File"
message={`Delete "${fileToDelete.split('/').pop()}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate(fileToDelete);
setFileToDelete(null);
}}
onCancel={() => setFileToDelete(null)}
/>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react';
import { WebGLPreview, init } from 'gcode-preview';
import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
interface BuildVolume {
x: number;
y: number;
z: number;
}
interface GcodeViewerProps {
gcodeUrl: string;
buildVolume?: BuildVolume;
className?: string;
}
export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: GcodeViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const previewRef = useRef<WebGLPreview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notSliced, setNotSliced] = useState(false);
const [currentLayer, setCurrentLayer] = useState(0);
const [totalLayers, setTotalLayers] = useState(0);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
// Initialize the preview
const preview = init({
canvas,
buildVolume: buildVolume,
backgroundColor: 0x1a1a1a,
travelColor: 0x444444,
extrusionColor: 0x00ae42,
topLayerColor: 0x00ff5a,
lastSegmentColor: 0xffffff,
lineWidth: 2,
renderTravel: false,
renderExtrusion: true,
});
previewRef.current = preview;
// Fetch and parse G-code
setLoading(true);
setError(null);
setNotSliced(false);
fetch(gcodeUrl)
.then(async response => {
if (!response.ok) {
if (response.status === 404) {
const data = await response.json().catch(() => ({}));
if (data.detail?.includes('sliced')) {
setNotSliced(true);
throw new Error('not_sliced');
}
}
throw new Error('Failed to load G-code');
}
return response.text();
})
.then(gcode => {
// Parse G-code
preview.processGCode(gcode);
// Get layer count
const layers = preview.layers?.length || 0;
setTotalLayers(layers);
setCurrentLayer(layers);
// Render all layers initially
preview.render();
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
// Handle resize
const handleResize = () => {
if (canvas.parentElement) {
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
preview.resize();
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
preview.dispose();
};
}, [gcodeUrl, buildVolume]);
const handleLayerChange = (layer: number) => {
if (!previewRef.current) return;
const newLayer = Math.max(1, Math.min(layer, totalLayers));
setCurrentLayer(newLayer);
// Clear and re-render up to the specified layer
previewRef.current.render();
};
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleLayerChange(parseInt(e.target.value, 10));
};
return (
<div className={`relative flex flex-col h-full ${className}`}>
{/* Canvas container */}
<div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-full"
/>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green mx-auto mb-2" />
<p className="text-bambu-gray text-sm">Loading G-code...</p>
</div>
</div>
)}
{notSliced && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center max-w-sm px-4">
<FileWarning className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
<p className="text-white font-medium mb-2">G-code not available</p>
<p className="text-bambu-gray text-sm">
This file hasn't been sliced yet. G-code preview is only available
after slicing the model in Bambu Studio or Orca Slicer.
</p>
</div>
</div>
)}
{error && !notSliced && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<div className="text-center text-red-400">
<p className="text-sm">{error}</p>
</div>
</div>
)}
</div>
{/* Layer controls */}
{!loading && !error && !notSliced && totalLayers > 0 && (
<div className="mt-4 px-2">
<div className="flex items-center gap-3">
<Layers className="w-4 h-4 text-bambu-gray flex-shrink-0" />
<button
onClick={() => handleLayerChange(currentLayer - 1)}
disabled={currentLayer <= 1}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<input
type="range"
min={1}
max={totalLayers}
value={currentLayer}
onChange={handleSliderChange}
className="flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green"
/>
<button
onClick={() => handleLayerChange(currentLayer + 1)}
disabled={currentLayer >= totalLayers}
className="p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
<span className="text-sm text-bambu-gray min-w-[80px] text-right">
{currentLayer} / {totalLayers}
</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,85 @@
import { X, Keyboard } from 'lucide-react';
import { Card, CardContent } from './Card';
interface KeyboardShortcutsModalProps {
onClose: () => void;
}
const shortcuts = [
{ category: 'Navigation', items: [
{ keys: ['1'], description: 'Go to Printers' },
{ keys: ['2'], description: 'Go to Archives' },
{ keys: ['3'], description: 'Go to Statistics' },
{ keys: ['4'], description: 'Go to Cloud Profiles' },
{ keys: ['5'], description: 'Go to Settings' },
]},
{ category: 'Archives', items: [
{ keys: ['/'], description: 'Focus search' },
{ keys: ['U'], description: 'Open upload modal' },
{ keys: ['Esc'], description: 'Clear selection / blur input' },
{ keys: ['Right-click'], description: 'Context menu on cards' },
]},
{ category: 'General', items: [
{ keys: ['?'], description: 'Show this help' },
]},
];
function KeyBadge({ children }: { children: string }) {
return (
<kbd className="px-2 py-1 text-xs font-mono bg-bambu-dark border border-bambu-dark-tertiary rounded text-white">
{children}
</kbd>
);
}
export function KeyboardShortcutsModal({ onClose }: KeyboardShortcutsModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Keyboard className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Shortcuts List */}
<div className="p-4 space-y-6 max-h-[60vh] overflow-y-auto">
{shortcuts.map((section) => (
<div key={section.category}>
<h3 className="text-sm font-medium text-bambu-gray mb-3">{section.category}</h3>
<div className="space-y-2">
{section.items.map((shortcut) => (
<div key={shortcut.description} className="flex items-center justify-between">
<span className="text-white text-sm">{shortcut.description}</span>
<div className="flex gap-1">
{shortcut.keys.map((key) => (
<KeyBadge key={key}>{key}</KeyBadge>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="p-4 border-t border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray text-center">
Press <KeyBadge>Esc</KeyBadge> or click outside to close
</p>
</div>
</CardContent>
</Card>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { useState, useEffect, useCallback } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { Printer, Archive, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
const navItems = [
{ to: '/', icon: Printer, label: 'Printers' },
{ to: '/archives', icon: Archive, label: 'Archives' },
{ to: '/stats', icon: BarChart3, label: 'Statistics' },
{ to: '/cloud', icon: Cloud, label: 'Cloud Profiles' },
{ to: '/settings', icon: Settings, label: 'Settings' },
];
export function Layout() {
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
});
const [showShortcuts, setShowShortcuts] = useState(false);
useEffect(() => {
localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
}, [sidebarExpanded]);
// Global keyboard shortcuts for navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const target = e.target as HTMLElement;
// Ignore if typing in an input/textarea
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Number keys for navigation (1-4)
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
switch (e.key) {
case '1':
e.preventDefault();
navigate('/');
break;
case '2':
e.preventDefault();
navigate('/archives');
break;
case '3':
e.preventDefault();
navigate('/stats');
break;
case '4':
e.preventDefault();
navigate('/cloud');
break;
case '5':
e.preventDefault();
navigate('/settings');
break;
case '?':
e.preventDefault();
setShowShortcuts(true);
break;
case 'Escape':
setShowShortcuts(false);
break;
}
}
}, [navigate]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<aside
className={`${sidebarExpanded ? 'w-64' : 'w-16'} bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col fixed inset-y-0 left-0 z-30 transition-all duration-300`}
>
{/* Logo */}
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-center overflow-hidden">
<div className={`${sidebarExpanded ? '' : 'w-10 h-10 overflow-hidden'}`}>
<img
src={theme === 'dark' ? '/img/bambusy_logo_dark.png' : '/img/bambusy_logo_light.png'}
alt="Bambusy"
className={sidebarExpanded ? 'h-16 w-auto' : 'h-10 w-auto max-w-none'}
/>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-2">
<ul className="space-y-2">
{navItems.map(({ to, icon: Icon, label }) => (
<li key={to}>
<NavLink
to={to}
className={({ isActive }) =>
`flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!sidebarExpanded ? label : undefined}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{sidebarExpanded && <span>{label}</span>}
</NavLink>
</li>
))}
</ul>
</nav>
{/* Collapse toggle */}
<button
onClick={() => setSidebarExpanded(!sidebarExpanded)}
className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
>
{sidebarExpanded ? (
<ChevronLeft className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
{/* Footer */}
<div className="p-2 border-t border-bambu-dark-tertiary">
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-2' : 'flex-col gap-2'}`}>
{sidebarExpanded && <span className="text-sm text-bambu-gray">v0.1.1</span>}
<div className="flex items-center gap-1">
<a
href="https://github.com/maziggy/bambusy"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="View on GitHub"
>
<Github className="w-5 h-5" />
</a>
<button
onClick={() => setShowShortcuts(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="Keyboard shortcuts (?)"
>
<Keyboard className="w-5 h-5" />
</button>
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
</div>
</div>
</div>
</aside>
{/* Main content */}
<main className={`flex-1 bg-bambu-dark overflow-auto ${sidebarExpanded ? 'ml-64' : 'ml-16'} transition-all duration-300`}>
<Outlet />
</main>
{/* Keyboard Shortcuts Modal */}
{showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} />}
</div>
);
}
+515
View File
@@ -0,0 +1,515 @@
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import JSZip from 'jszip';
import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from './Button';
interface BuildVolume {
x: number;
y: number;
z: number;
}
interface ModelViewerProps {
url: string;
buildVolume?: BuildVolume;
className?: string;
}
interface MeshData {
vertices: number[];
triangles: number[];
}
interface ObjectData {
id: string;
meshes: MeshData[];
}
interface BuildItem {
objectId: string;
transform: THREE.Matrix4;
}
// Parse 3MF transform - keep in 3MF coordinate space (Z-up)
function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
const matrix = new THREE.Matrix4();
if (!transformStr) {
return matrix; // Identity matrix
}
// 3MF transform is a 3x4 affine matrix in row-major order:
// "m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32"
// Where (m30, m31, m32) is the translation vector
const values = transformStr.trim().split(/\s+/).map(parseFloat);
if (values.length >= 12) {
// Three.js Matrix4.set takes row-major order arguments:
// set(n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44)
// 3MF row-major: m00, m01, m02, m10, m11, m12, m20, m21, m22, m30, m31, m32
matrix.set(
values[0], values[1], values[2], values[9], // m00, m01, m02, tx
values[3], values[4], values[5], values[10], // m10, m11, m12, ty
values[6], values[7], values[8], values[11], // m20, m21, m22, tz
0, 0, 0, 1
);
}
return matrix;
}
// Alias for backwards compatibility
const parseTransform = parseTransform3MF;
async function parseMeshFromDoc(doc: Document): Promise<MeshData[]> {
const meshes: MeshData[] = [];
const meshElements = doc.getElementsByTagName('mesh');
for (let j = 0; j < meshElements.length; j++) {
const meshEl = meshElements[j];
const vertices: number[] = [];
const triangles: number[] = [];
const vertexElements = meshEl.getElementsByTagName('vertex');
for (let k = 0; k < vertexElements.length; k++) {
const v = vertexElements[k];
vertices.push(
parseFloat(v.getAttribute('x') || '0'),
parseFloat(v.getAttribute('y') || '0'),
parseFloat(v.getAttribute('z') || '0')
);
}
const triangleElements = meshEl.getElementsByTagName('triangle');
for (let k = 0; k < triangleElements.length; k++) {
const t = triangleElements[k];
triangles.push(
parseInt(t.getAttribute('v1') || '0'),
parseInt(t.getAttribute('v2') || '0'),
parseInt(t.getAttribute('v3') || '0')
);
}
if (vertices.length > 0 && triangles.length > 0) {
meshes.push({ vertices, triangles });
}
}
return meshes;
}
async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string, ObjectData>; buildItems: BuildItem[] }> {
const zip = await JSZip.loadAsync(arrayBuffer);
const objects = new Map<string, ObjectData>();
const buildItems: BuildItem[] = [];
const parser = new DOMParser();
// Helper to load and parse a model file from the zip
async function loadModelFile(path: string): Promise<Document | null> {
// Normalize path (remove leading slash)
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
const file = zip.files[normalizedPath];
if (!file) return null;
const content = await file.async('string');
return parser.parseFromString(content, 'application/xml');
}
// Find the main 3D model file
const mainModelPath = Object.keys(zip.files).find(
(name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
);
if (!mainModelPath) {
// Fallback: try to find any .model file
const anyModelPath = Object.keys(zip.files).find((name) => name.endsWith('.model'));
if (anyModelPath) {
const doc = await loadModelFile(anyModelPath);
if (doc) {
const meshes = await parseMeshFromDoc(doc);
if (meshes.length > 0) {
objects.set('1', { id: '1', meshes });
}
}
}
return { objects, buildItems };
}
const mainDoc = await loadModelFile(mainModelPath);
if (!mainDoc) return { objects, buildItems };
// Parse objects - Bambu Studio uses components to reference external files
const objectElements = mainDoc.getElementsByTagName('object');
for (let i = 0; i < objectElements.length; i++) {
const objEl = objectElements[i];
const objectId = objEl.getAttribute('id');
if (!objectId) continue;
const meshes: MeshData[] = [];
// Check for direct mesh in this object
const objMeshElements = objEl.getElementsByTagName('mesh');
for (let j = 0; j < objMeshElements.length; j++) {
const meshEl = objMeshElements[j];
const vertices: number[] = [];
const triangles: number[] = [];
const vertexElements = meshEl.getElementsByTagName('vertex');
for (let k = 0; k < vertexElements.length; k++) {
const v = vertexElements[k];
vertices.push(
parseFloat(v.getAttribute('x') || '0'),
parseFloat(v.getAttribute('y') || '0'),
parseFloat(v.getAttribute('z') || '0')
);
}
const triangleElements = meshEl.getElementsByTagName('triangle');
for (let k = 0; k < triangleElements.length; k++) {
const t = triangleElements[k];
triangles.push(
parseInt(t.getAttribute('v1') || '0'),
parseInt(t.getAttribute('v2') || '0'),
parseInt(t.getAttribute('v3') || '0')
);
}
if (vertices.length > 0 && triangles.length > 0) {
meshes.push({ vertices, triangles });
}
}
// Check for component references (Bambu Studio style)
const componentElements = objEl.getElementsByTagName('component');
for (let j = 0; j < componentElements.length; j++) {
const compEl = componentElements[j];
// p:path attribute contains the external file reference
const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
if (extPath) {
const extDoc = await loadModelFile(extPath);
if (extDoc) {
const extMeshes = await parseMeshFromDoc(extDoc);
// Apply component transform if present
const compTransformStr = compEl.getAttribute('transform');
const compTransform = parseTransform(compTransformStr);
for (const mesh of extMeshes) {
if (compTransformStr) {
// Apply transform to vertices (in 3MF coordinate space, before Y/Z swap)
const transformedVertices: number[] = [];
for (let k = 0; k < mesh.vertices.length; k += 3) {
const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]);
v.applyMatrix4(compTransform);
transformedVertices.push(v.x, v.y, v.z);
}
meshes.push({ vertices: transformedVertices, triangles: mesh.triangles });
} else {
meshes.push(mesh);
}
}
}
}
}
if (meshes.length > 0) {
objects.set(objectId, { id: objectId, meshes });
}
}
// Parse build items (placement on build plate)
const buildElements = mainDoc.getElementsByTagName('build');
if (buildElements.length > 0) {
const itemElements = buildElements[0].getElementsByTagName('item');
for (let i = 0; i < itemElements.length; i++) {
const itemEl = itemElements[i];
const objectId = itemEl.getAttribute('objectid');
if (!objectId) continue;
const transform = parseTransform(itemEl.getAttribute('transform'));
buildItems.push({ objectId, transform });
}
}
return { objects, buildItems };
}
function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry();
// Convert from 3MF Z-up to Three.js Y-up coordinate system
// 3MF: X right, Y back, Z up -> Three.js: X right, Y up, Z forward
const positions = new Float32Array(mesh.vertices.length);
for (let i = 0; i < mesh.vertices.length; i += 3) {
positions[i] = mesh.vertices[i]; // X stays X
positions[i + 1] = mesh.vertices[i + 2]; // Y becomes Z (up)
positions[i + 2] = mesh.vertices[i + 1]; // Z becomes Y
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex(mesh.triangles);
// Compute normals
geometry.computeVertexNormals();
return geometry;
}
export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: ModelViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
sceneRef.current = scene;
// Camera
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
camera.position.set(150, 150, 150);
cameraRef.current = camera;
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controlsRef.current = controls;
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(100, 100, 100);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
directionalLight2.position.set(-100, 50, -100);
scene.add(directionalLight2);
// Grid - use the larger dimension for the grid size
const gridSize = Math.max(buildVolume.x, buildVolume.y);
const gridDivisions = Math.ceil(gridSize / 16);
const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);
scene.add(gridHelper);
// Build plate indicator
const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
const plateMaterial = new THREE.MeshBasicMaterial({
color: 0x00ae42,
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide,
});
const plate = new THREE.Mesh(plateGeometry, plateMaterial);
plate.rotation.x = -Math.PI / 2;
plate.position.y = -0.5; // Slightly below Y=0 so models sit on top
scene.add(plate);
// Animation loop - keep it simple for reliability
let animationId: number;
const animate = () => {
animationId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
// Load 3MF
fetch(url)
.then((res) => {
if (!res.ok) throw new Error('Failed to load file');
return res.arrayBuffer();
})
.then(parse3MF)
.then(({ objects, buildItems }) => {
if (objects.size === 0) {
throw new Error('No meshes found in 3MF file');
}
const material = new THREE.MeshPhongMaterial({
color: 0x00ae42,
shininess: 30,
flatShading: false,
});
const group = new THREE.Group();
const allGeometries: THREE.BufferGeometry[] = [];
// If we have build items, use them for positioning
if (buildItems.length > 0) {
for (const item of buildItems) {
const objectData = objects.get(item.objectId);
if (!objectData) continue;
for (const meshData of objectData.meshes) {
// Apply build transform to vertices in 3MF space BEFORE coordinate conversion
const transformedVertices: number[] = [];
for (let k = 0; k < meshData.vertices.length; k += 3) {
const v = new THREE.Vector3(
meshData.vertices[k],
meshData.vertices[k + 1],
meshData.vertices[k + 2]
);
v.applyMatrix4(item.transform);
transformedVertices.push(v.x, v.y, v.z);
}
// Now create geometry with coordinate conversion
const geometry = createGeometryFromMesh({
vertices: transformedVertices,
triangles: meshData.triangles,
});
allGeometries.push(geometry);
}
}
} else {
// Fallback: just add all objects without transforms
for (const objectData of objects.values()) {
for (const meshData of objectData.meshes) {
const geometry = createGeometryFromMesh(meshData);
allGeometries.push(geometry);
}
}
}
// Merge all geometries into one for better performance
if (allGeometries.length > 0) {
const mergedGeometry = allGeometries.length === 1
? allGeometries[0]
: mergeGeometries(allGeometries, false);
if (mergedGeometry) {
const mesh = new THREE.Mesh(mergedGeometry, material);
group.add(mesh);
}
// Dispose individual geometries if merged
if (allGeometries.length > 1) {
for (const geom of allGeometries) {
geom.dispose();
}
}
}
// Get bounding box to position model
const box = new THREE.Box3().setFromObject(group);
const center = box.getCenter(new THREE.Vector3());
// Always place models on the build plate (Y=0)
group.position.y = -box.min.y;
// For models without build transforms, also center X/Z
if (buildItems.length === 0) {
group.position.x = -center.x;
group.position.z = -center.z;
}
scene.add(group);
// Recalculate bounding box after positioning
const finalBox = new THREE.Box3().setFromObject(group);
const finalCenter = finalBox.getCenter(new THREE.Vector3());
const finalSize = finalBox.getSize(new THREE.Vector3());
// Adjust camera to fit model
const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
const cameraDistance = maxDim * 1.8;
camera.position.set(
finalCenter.x + cameraDistance * 0.7,
finalCenter.y + cameraDistance * 0.5,
finalCenter.z + cameraDistance * 0.7
);
controls.target.copy(finalCenter);
controls.update();
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
// Handle resize
const handleResize = () => {
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(animationId);
controls.dispose();
renderer.dispose();
container.removeChild(renderer.domElement);
};
}, [url, buildVolume]);
const resetView = () => {
if (cameraRef.current && controlsRef.current) {
cameraRef.current.position.set(150, 150, 150);
controlsRef.current.target.set(0, 50, 0);
controlsRef.current.update();
}
};
const zoom = (factor: number) => {
if (cameraRef.current) {
cameraRef.current.position.multiplyScalar(factor);
}
};
return (
<div className={`relative ${className}`}>
<div ref={containerRef} className="w-full h-full min-h-[400px]" />
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
<p className="text-red-400">{error}</p>
</div>
)}
{!loading && !error && (
<div className="absolute bottom-4 right-4 flex gap-2">
<Button variant="secondary" size="sm" onClick={() => zoom(0.8)}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={() => zoom(1.25)}>
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={resetView}>
<RotateCcw className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { X, ExternalLink, Box, Code2, Loader2 } from 'lucide-react';
import { ModelViewer } from './ModelViewer';
import { GcodeViewer } from './GcodeViewer';
import { Button } from './Button';
import { api } from '../api/client';
type ViewTab = '3d' | 'gcode';
interface ModelViewerModalProps {
archiveId: number;
title: string;
onClose: () => void;
}
interface Capabilities {
has_model: boolean;
has_gcode: boolean;
build_volume: { x: number; y: number; z: number };
}
export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModalProps) {
const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
const [loading, setLoading] = useState(true);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
api.getArchiveCapabilities(archiveId)
.then(caps => {
setCapabilities(caps);
// Auto-select the first available tab
if (caps.has_model) {
setActiveTab('3d');
} else if (caps.has_gcode) {
setActiveTab('gcode');
}
setLoading(false);
})
.catch(() => {
// Fallback to 3D model tab if capabilities check fails
setCapabilities({ has_model: true, has_gcode: false, build_volume: { x: 256, y: 256, z: 256 } });
setActiveTab('3d');
setLoading(false);
});
}, [archiveId]);
const handleOpenInSlicer = () => {
// Use bambustudioopen:// protocol like MakerWorld does
// URL must include .3mf filename for Bambu Studio to recognize the format
const filename = title || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
};
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white truncate flex-1 mr-4">{title}</h2>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer}>
<ExternalLink className="w-4 h-4" />
Open in Slicer
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{/* Tabs - only show if we have capabilities */}
{capabilities && (
<div className="flex border-b border-bambu-dark-tertiary">
<button
onClick={() => capabilities.has_model && setActiveTab('3d')}
disabled={!capabilities.has_model}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === '3d'
? 'text-bambu-green border-b-2 border-bambu-green'
: capabilities.has_model
? 'text-bambu-gray hover:text-white'
: 'text-bambu-gray/30 cursor-not-allowed'
}`}
>
<Box className="w-4 h-4" />
3D Model
{!capabilities.has_model && <span className="text-xs">(not available)</span>}
</button>
<button
onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
disabled={!capabilities.has_gcode}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
activeTab === 'gcode'
? 'text-bambu-green border-b-2 border-bambu-green'
: capabilities.has_gcode
? 'text-bambu-gray hover:text-white'
: 'text-bambu-gray/30 cursor-not-allowed'
}`}
>
<Code2 className="w-4 h-4" />
G-code Preview
{!capabilities.has_gcode && <span className="text-xs">(not sliced)</span>}
</button>
</div>
)}
{/* Viewer */}
<div className="flex-1 overflow-hidden p-4">
{loading ? (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
) : activeTab === '3d' && capabilities ? (
<ModelViewer
url={api.getArchiveDownload(archiveId)}
buildVolume={capabilities.build_volume}
className="w-full h-full"
/>
) : activeTab === 'gcode' && capabilities ? (
<GcodeViewer
gcodeUrl={api.getArchiveGcode(archiveId)}
buildVolume={capabilities.build_volume}
className="w-full h-full"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
No preview available for this file
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2 } from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
interface PhotoGalleryModalProps {
archiveId: number;
archiveName: string;
photos: string[];
onClose: () => void;
onDelete?: (filename: string) => void;
}
export function PhotoGalleryModal({
archiveId,
archiveName,
photos,
onClose,
onDelete,
}: PhotoGalleryModalProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
if (e.key === 'ArrowRight') setCurrentIndex((i) => Math.min(photos.length - 1, i + 1));
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, photos.length]);
// Reset index if photos change
useEffect(() => {
if (currentIndex >= photos.length) {
setCurrentIndex(Math.max(0, photos.length - 1));
}
}, [photos.length, currentIndex]);
if (photos.length === 0) {
onClose();
return null;
}
const currentPhoto = photos[currentIndex];
const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);
const handleDownload = () => {
const link = document.createElement('a');
link.href = photoUrl;
link.download = `${archiveName}_photo_${currentIndex + 1}.jpg`;
link.click();
};
const handleDelete = () => {
if (onDelete) {
setShowDeleteConfirm(true);
}
};
return (
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="relative w-full h-full flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-black/50">
<div>
<h2 className="text-lg font-semibold text-white">{archiveName}</h2>
<p className="text-sm text-bambu-gray">
Photo {currentIndex + 1} of {photos.length}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={handleDownload}>
<Download className="w-4 h-4" />
Download
</Button>
{onDelete && (
<Button variant="secondary" size="sm" onClick={handleDelete} className="text-red-400 hover:text-red-300">
<Trash2 className="w-4 h-4" />
</Button>
)}
<button
onClick={onClose}
className="p-2 text-bambu-gray hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* Image */}
<div className="flex-1 min-h-0 flex items-center justify-center p-4 relative overflow-hidden">
{/* Previous button */}
{currentIndex > 0 && (
<button
onClick={() => setCurrentIndex((i) => i - 1)}
className="absolute left-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
>
<ChevronLeft className="w-8 h-8 text-white" />
</button>
)}
{/* Image */}
<img
src={photoUrl}
alt={`Photo ${currentIndex + 1}`}
className="max-w-full max-h-full object-contain rounded-lg"
style={{ maxHeight: 'calc(100vh - 200px)' }}
/>
{/* Next button */}
{currentIndex < photos.length - 1 && (
<button
onClick={() => setCurrentIndex((i) => i + 1)}
className="absolute right-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
>
<ChevronRight className="w-8 h-8 text-white" />
</button>
)}
</div>
{/* Thumbnails */}
{photos.length > 1 && (
<div className="flex justify-center gap-2 p-4 bg-black/50">
{photos.map((photo, index) => (
<button
key={photo}
onClick={() => setCurrentIndex(index)}
className={`w-16 h-16 rounded-lg overflow-hidden border-2 transition-colors ${
index === currentIndex
? 'border-bambu-green'
: 'border-transparent hover:border-bambu-gray'
}`}
>
<img
src={api.getArchivePhotoUrl(archiveId, photo)}
alt={`Thumbnail ${index + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Photo"
message="Delete this photo? This cannot be undone."
confirmText="Delete"
variant="danger"
onConfirm={() => {
onDelete?.(currentPhoto);
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useMemo } from 'react';
interface PrintCalendarProps {
printDates: string[]; // Array of ISO date strings
months?: number; // How many months to show (default 3)
}
export function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {
const { weeks, monthLabels, printCounts } = useMemo(() => {
// Count prints per day
const counts: Record<string, number> = {};
printDates.forEach((date) => {
const day = date.split('T')[0];
counts[day] = (counts[day] || 0) + 1;
});
// Generate weeks for the last N months
const today = new Date();
const startDate = new Date(today);
startDate.setMonth(startDate.getMonth() - months);
startDate.setDate(startDate.getDate() - startDate.getDay()); // Start from Sunday
const weeks: Date[][] = [];
const monthLabels: { month: string; weekIndex: number }[] = [];
let currentWeek: Date[] = [];
let lastMonth = -1;
const current = new Date(startDate);
let weekIndex = 0;
while (current <= today) {
if (current.getDay() === 0 && currentWeek.length > 0) {
weeks.push(currentWeek);
currentWeek = [];
weekIndex++;
}
// Track month labels
if (current.getMonth() !== lastMonth) {
monthLabels.push({
month: current.toLocaleDateString('en-US', { month: 'short' }),
weekIndex,
});
lastMonth = current.getMonth();
}
currentWeek.push(new Date(current));
current.setDate(current.getDate() + 1);
}
if (currentWeek.length > 0) {
weeks.push(currentWeek);
}
return { weeks, monthLabels, printCounts: counts };
}, [printDates, months]);
const maxCount = Math.max(1, ...Object.values(printCounts));
const getColor = (count: number) => {
if (count === 0) return 'bg-bambu-dark';
const intensity = count / maxCount;
if (intensity <= 0.25) return 'bg-bambu-green/30';
if (intensity <= 0.5) return 'bg-bambu-green/50';
if (intensity <= 0.75) return 'bg-bambu-green/75';
return 'bg-bambu-green';
};
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return (
<div className="overflow-x-auto">
{/* Month labels */}
<div className="flex mb-1 ml-8">
{monthLabels.map(({ month, weekIndex }, i) => (
<div
key={i}
className="text-xs text-bambu-gray"
style={{ marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * 14 - 24}px` }}
>
{month}
</div>
))}
</div>
<div className="flex gap-0.5">
{/* Day labels */}
<div className="flex flex-col gap-0.5 mr-1">
{dayLabels.map((day, i) => (
<div
key={day}
className="h-3 text-xs text-bambu-gray flex items-center"
style={{ visibility: i % 2 === 1 ? 'visible' : 'hidden' }}
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-0.5">
{[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {
const day = week.find((d) => d.getDay() === dayOfWeek);
if (!day) {
return <div key={dayOfWeek} className="w-3 h-3" />;
}
const dateStr = day.toISOString().split('T')[0];
const count = printCounts[dateStr] || 0;
const isToday = dateStr === new Date().toISOString().split('T')[0];
return (
<div
key={dayOfWeek}
className={`w-3 h-3 rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}
title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}
/>
);
})}
</div>
))}
</div>
{/* Legend */}
<div className="flex items-center gap-2 mt-3 text-xs text-bambu-gray">
<span>Less</span>
<div className="flex gap-0.5">
<div className="w-3 h-3 rounded-sm bg-bambu-dark" />
<div className="w-3 h-3 rounded-sm bg-bambu-green/30" />
<div className="w-3 h-3 rounded-sm bg-bambu-green/50" />
<div className="w-3 h-3 rounded-sm bg-bambu-green/75" />
<div className="w-3 h-3 rounded-sm bg-bambu-green" />
</div>
<span>More</span>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { X, Download } from 'lucide-react';
import { Button } from './Button';
import { api } from '../api/client';
interface QRCodeModalProps {
archiveId: number;
archiveName: string;
onClose: () => void;
}
export function QRCodeModal({ archiveId, archiveName, onClose }: QRCodeModalProps) {
const qrCodeUrl = api.getArchiveQRCodeUrl(archiveId, 300);
const handleDownload = () => {
const link = document.createElement('a');
link.href = qrCodeUrl;
link.download = `${archiveName}_qrcode.png`;
link.click();
};
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-sm"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">QR Code</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 flex flex-col items-center">
<p className="text-sm text-bambu-gray mb-4 text-center truncate max-w-full">
{archiveName}
</p>
<div className="bg-white p-4 rounded-lg mb-4">
<img
src={qrCodeUrl}
alt="QR Code"
className="w-64 h-64"
/>
</div>
<p className="text-xs text-bambu-gray mb-4 text-center">
Scan to open this archive
</p>
<Button onClick={handleDownload} className="w-full">
<Download className="w-4 h-4" />
Download QR Code
</Button>
</div>
</div>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { X, Printer, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
interface ReprintModalProps {
archiveId: number;
archiveName: string;
onClose: () => void;
onSuccess: () => void;
}
export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
const { data: printers, isLoading: loadingPrinters } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const reprintMutation = useMutation({
mutationFn: () => {
if (!selectedPrinter) throw new Error('No printer selected');
return api.reprintArchive(archiveId, selectedPrinter);
},
onSuccess: () => {
onSuccess();
onClose();
},
});
const activePrinters = printers?.filter((p) => p.is_active) || [];
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
<Card className="w-full max-w-md">
<CardContent>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Re-print</h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<p className="text-sm text-bambu-gray mb-4">
Send <span className="text-white">{archiveName}</span> to a printer
</p>
{/* Printer selection */}
{loadingPrinters ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : activePrinters.length === 0 ? (
<div className="text-center py-8 text-bambu-gray">
No active printers available
</div>
) : (
<div className="space-y-2 mb-6">
{activePrinters.map((printer) => (
<button
key={printer.id}
onClick={() => setSelectedPrinter(printer.id)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
selectedPrinter === printer.id
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
}`}
>
<div
className={`p-2 rounded-lg ${
selectedPrinter === printer.id
? 'bg-bambu-green/20'
: 'bg-bambu-dark-tertiary'
}`}
>
<Printer
className={`w-5 h-5 ${
selectedPrinter === printer.id
? 'text-bambu-green'
: 'text-bambu-gray'
}`}
/>
</div>
<div className="text-left">
<p className="text-white font-medium">{printer.name}</p>
<p className="text-xs text-bambu-gray">
{printer.model || 'Unknown model'} • {printer.ip_address}
</p>
</div>
</button>
))}
</div>
)}
{/* Error message */}
{reprintMutation.isError && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
{(reprintMutation.error as Error).message || 'Failed to start print'}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
onClick={() => reprintMutation.mutate()}
disabled={!selectedPrinter || reprintMutation.isPending}
className="flex-1"
>
{reprintMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sending...
</>
) : (
<>
<Printer className="w-4 h-4" />
Print
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+302
View File
@@ -0,0 +1,302 @@
import { useState, useCallback, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import type { BulkUploadResult } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface FileWithStatus {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
archiveId?: number;
}
interface UploadModalProps {
onClose: () => void;
initialFiles?: File[];
}
export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<FileWithStatus[]>(() =>
initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
);
const [isDragging, setIsDragging] = useState(false);
const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const uploadMutation = useMutation({
mutationFn: (filesToUpload: File[]) =>
api.uploadArchivesBulk(filesToUpload, selectedPrinter),
onSuccess: (result) => {
setUploadResult(result);
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
// Update file statuses based on result
setFiles((prev) =>
prev.map((f) => {
const success = result.results.find((r) => r.filename === f.file.name);
const error = result.errors.find((e) => e.filename === f.file.name);
if (success) {
return { ...f, status: 'success', archiveId: success.id };
}
if (error) {
return { ...f, status: 'error', error: error.error };
}
return f;
})
);
// Show toast
if (result.failed === 0) {
showToast(`${result.uploaded} file${result.uploaded !== 1 ? 's' : ''} uploaded`);
} else if (result.uploaded === 0) {
showToast(`Failed to upload ${result.failed} file${result.failed !== 1 ? 's' : ''}`, 'error');
} else {
showToast(`${result.uploaded} uploaded, ${result.failed} failed`, 'warning');
}
},
onError: () => {
setFiles((prev) =>
prev.map((f) => ({ ...f, status: 'error', error: 'Upload failed' }))
);
showToast('Upload failed', 'error');
},
});
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter((f) =>
f.name.endsWith('.3mf')
);
if (droppedFiles.length > 0) {
setFiles((prev) => [
...prev,
...droppedFiles.map((file) => ({ file, status: 'pending' as const })),
]);
}
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []).filter((f) =>
f.name.endsWith('.3mf')
);
if (selectedFiles.length > 0) {
setFiles((prev) => [
...prev,
...selectedFiles.map((file) => ({ file, status: 'pending' as const })),
]);
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, []);
const removeFile = useCallback((index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleUpload = () => {
if (files.length === 0) return;
const pendingFiles = files.filter((f) => f.status === 'pending');
if (pendingFiles.length === 0) return;
setFiles((prev) =>
prev.map((f) =>
f.status === 'pending' ? { ...f, status: 'uploading' } : f
)
);
uploadMutation.mutate(pendingFiles.map((f) => f.file));
};
const pendingCount = files.filter((f) => f.status === 'pending').length;
const isUploading = uploadMutation.isPending;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
<CardContent className="p-0 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Drop Zone */}
<div className="p-4">
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
<p className="text-white mb-2">
Drag & drop .3mf files here
</p>
<p className="text-bambu-gray text-sm mb-4">or</p>
<Button
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
Browse Files
</Button>
<input
ref={fileInputRef}
type="file"
accept=".3mf"
multiple
className="hidden"
onChange={handleFileSelect}
/>
</div>
</div>
{/* Optional Printer Selection */}
<div className="px-4 pb-4">
<label className="block text-sm text-bambu-gray mb-2">
Associate with printer (optional)
</label>
<select
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={selectedPrinter || ''}
onChange={(e) =>
setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
}
disabled={isUploading}
>
<option value="">No printer</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* File List */}
{files.length > 0 && (
<div className="px-4 pb-4 max-h-60 overflow-y-auto">
<div className="space-y-2">
{files.map((f, index) => (
<div
key={`${f.file.name}-${index}`}
className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg"
>
<File className="w-5 h-5 text-bambu-gray flex-shrink-0" />
<span className="flex-1 text-white text-sm truncate">
{f.file.name}
</span>
<span className="text-xs text-bambu-gray">
{(f.file.size / (1024 * 1024)).toFixed(1)} MB
</span>
{f.status === 'pending' && (
<button
onClick={() => removeFile(index)}
className="text-bambu-gray hover:text-red-400 transition-colors"
disabled={isUploading}
>
<X className="w-4 h-4" />
</button>
)}
{f.status === 'uploading' && (
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
)}
{f.status === 'success' && (
<CheckCircle className="w-4 h-4 text-bambu-green" />
)}
{f.status === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400">{f.error}</span>
<AlertCircle className="w-4 h-4 text-red-400" />
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Upload Result Summary */}
{uploadResult && (
<div className="px-4 pb-4">
<div className="p-3 bg-bambu-dark rounded-lg">
<p className="text-sm text-white">
<span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
{uploadResult.failed > 0 && (
<>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
)}
</p>
</div>
</div>
)}
{/* Footer */}
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose} className="flex-1">
{uploadResult ? 'Close' : 'Cancel'}
</Button>
{!uploadResult && (
<Button
onClick={handleUpload}
disabled={pendingCount === 0 || isUploading}
className="flex-1"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount > 0 && `(${pendingCount})`}
</>
)}
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}