| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 |
- import { useState, useMemo } from 'react';
- import { ChevronLeft, ChevronRight } from 'lucide-react';
- import type { Archive } from '../api/client';
- import { api } from '../api/client';
- import { parseUTCDate } from '../utils/date';
- interface CalendarViewProps {
- archives: Archive[];
- onArchiveClick?: (archive: Archive) => void;
- highlightedArchiveId?: number | null;
- }
- 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, highlightedArchiveId }: CalendarViewProps) {
- const today = new Date();
- const [currentMonth, setCurrentMonth] = useState(today.getMonth());
- const [currentYear, setCurrentYear] = useState(today.getFullYear());
- const [selectedDate, setSelectedDate] = useState<string | null>(null);
- const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
- // Group archives by date (using local timezone from UTC timestamps)
- const archivesByDate = useMemo(() => {
- const map = new Map<string, Archive[]>();
- archives.forEach(archive => {
- const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
- 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) || [] : [];
- // Clear selected archive when date changes
- const handleDateSelect = (dateKey: string | null) => {
- if (dateKey !== selectedDate) {
- setSelectedArchiveId(null);
- }
- setSelectedDate(dateKey);
- };
- 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={() => handleDateSelect(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 = parseUTCDate(a.completed_at || a.created_at) || new Date();
- 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 = parseUTCDate(a.completed_at || a.created_at) || new Date();
- 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 = parseUTCDate(a.completed_at || a.created_at) || new Date();
- 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="calendar-scroll space-y-2 max-h-96 overflow-y-auto">
- {selectedArchives.map(archive => {
- const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
- return (
- <button
- key={archive.id}
- onClick={() => {
- setSelectedArchiveId(archive.id);
- onArchiveClick?.(archive);
- }}
- className={`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${
- !isHighlighted ? 'hover:bg-bambu-dark-tertiary' : ''
- }`}
- style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
- >
- {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-black/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>
- );
- }
|