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