CalendarView.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import { useState, useMemo } from 'react';
  2. import { ChevronLeft, ChevronRight } from 'lucide-react';
  3. import type { Archive } from '../api/client';
  4. import { api } from '../api/client';
  5. import { parseUTCDate } from '../utils/date';
  6. interface CalendarViewProps {
  7. archives: Archive[];
  8. onArchiveClick?: (archive: Archive) => void;
  9. highlightedArchiveId?: number | null;
  10. }
  11. function getDaysInMonth(year: number, month: number): number {
  12. return new Date(year, month + 1, 0).getDate();
  13. }
  14. function getFirstDayOfMonth(year: number, month: number): number {
  15. return new Date(year, month, 1).getDay();
  16. }
  17. const MONTH_NAMES = [
  18. 'January', 'February', 'March', 'April', 'May', 'June',
  19. 'July', 'August', 'September', 'October', 'November', 'December'
  20. ];
  21. const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  22. export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }: CalendarViewProps) {
  23. const today = new Date();
  24. const [currentMonth, setCurrentMonth] = useState(today.getMonth());
  25. const [currentYear, setCurrentYear] = useState(today.getFullYear());
  26. const [selectedDate, setSelectedDate] = useState<string | null>(null);
  27. const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
  28. // Group archives by date (using local timezone from UTC timestamps)
  29. const archivesByDate = useMemo(() => {
  30. const map = new Map<string, Archive[]>();
  31. archives.forEach(archive => {
  32. const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
  33. const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
  34. const existing = map.get(key) || [];
  35. existing.push(archive);
  36. map.set(key, existing);
  37. });
  38. return map;
  39. }, [archives]);
  40. const daysInMonth = getDaysInMonth(currentYear, currentMonth);
  41. const firstDay = getFirstDayOfMonth(currentYear, currentMonth);
  42. const prevMonth = () => {
  43. if (currentMonth === 0) {
  44. setCurrentMonth(11);
  45. setCurrentYear(currentYear - 1);
  46. } else {
  47. setCurrentMonth(currentMonth - 1);
  48. }
  49. };
  50. const nextMonth = () => {
  51. if (currentMonth === 11) {
  52. setCurrentMonth(0);
  53. setCurrentYear(currentYear + 1);
  54. } else {
  55. setCurrentMonth(currentMonth + 1);
  56. }
  57. };
  58. const goToToday = () => {
  59. setCurrentMonth(today.getMonth());
  60. setCurrentYear(today.getFullYear());
  61. };
  62. // Build calendar grid
  63. const calendarDays: (number | null)[] = [];
  64. for (let i = 0; i < firstDay; i++) {
  65. calendarDays.push(null);
  66. }
  67. for (let day = 1; day <= daysInMonth; day++) {
  68. calendarDays.push(day);
  69. }
  70. const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
  71. // Clear selected archive when date changes
  72. const handleDateSelect = (dateKey: string | null) => {
  73. if (dateKey !== selectedDate) {
  74. setSelectedArchiveId(null);
  75. }
  76. setSelectedDate(dateKey);
  77. };
  78. return (
  79. <div className="flex flex-col lg:flex-row gap-6">
  80. {/* Calendar */}
  81. <div className="flex-1">
  82. {/* Header */}
  83. <div className="flex items-center justify-between mb-4">
  84. <button
  85. onClick={prevMonth}
  86. className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  87. >
  88. <ChevronLeft className="w-5 h-5 text-bambu-gray" />
  89. </button>
  90. <div className="flex items-center gap-3">
  91. <h2 className="text-lg font-semibold text-white">
  92. {MONTH_NAMES[currentMonth]} {currentYear}
  93. </h2>
  94. <button
  95. onClick={goToToday}
  96. 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"
  97. >
  98. Today
  99. </button>
  100. </div>
  101. <button
  102. onClick={nextMonth}
  103. className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  104. >
  105. <ChevronRight className="w-5 h-5 text-bambu-gray" />
  106. </button>
  107. </div>
  108. {/* Day headers */}
  109. <div className="grid grid-cols-7 gap-1 mb-1">
  110. {DAY_NAMES.map(day => (
  111. <div key={day} className="text-center text-xs text-bambu-gray py-2">
  112. {day}
  113. </div>
  114. ))}
  115. </div>
  116. {/* Calendar grid */}
  117. <div className="grid grid-cols-7 gap-1">
  118. {calendarDays.map((day, index) => {
  119. if (day === null) {
  120. return <div key={`empty-${index}`} className="aspect-square" />;
  121. }
  122. const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
  123. const dayArchives = archivesByDate.get(dateKey) || [];
  124. const hasArchives = dayArchives.length > 0;
  125. const isToday = day === today.getDate() && currentMonth === today.getMonth() && currentYear === today.getFullYear();
  126. const isSelected = dateKey === selectedDate;
  127. const successCount = dayArchives.filter(a => a.status === 'completed').length;
  128. const failedCount = dayArchives.filter(a => a.status === 'failed').length;
  129. return (
  130. <button
  131. key={day}
  132. onClick={() => handleDateSelect(isSelected ? null : dateKey)}
  133. className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${
  134. isSelected
  135. ? 'bg-bambu-green text-white'
  136. : isToday
  137. ? 'bg-bambu-green/20 text-white ring-2 ring-bambu-green'
  138. : hasArchives
  139. ? 'bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 text-white'
  140. : 'hover:bg-bambu-dark-tertiary/50 text-bambu-gray'
  141. }`}
  142. >
  143. <span className={`text-sm font-medium ${isToday && !isSelected ? 'text-bambu-green' : ''}`}>
  144. {day}
  145. </span>
  146. {hasArchives && (
  147. <div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex items-center gap-1">
  148. <div className={`w-2 h-2 rounded-full ${
  149. failedCount > 0 && successCount === 0
  150. ? 'bg-red-400'
  151. : failedCount > 0
  152. ? 'bg-yellow-400'
  153. : 'bg-green-400'
  154. }`} />
  155. <span className="text-xs font-medium">{dayArchives.length}</span>
  156. </div>
  157. )}
  158. </button>
  159. );
  160. })}
  161. </div>
  162. {/* Monthly stats */}
  163. <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
  164. <div className="grid grid-cols-3 gap-4 text-center">
  165. <div>
  166. <div className="text-2xl font-bold text-white">
  167. {archives.filter(a => {
  168. const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
  169. return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
  170. }).length}
  171. </div>
  172. <div className="text-xs text-bambu-gray">Prints this month</div>
  173. </div>
  174. <div>
  175. <div className="text-2xl font-bold text-green-400">
  176. {archives.filter(a => {
  177. const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
  178. return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed';
  179. }).length}
  180. </div>
  181. <div className="text-xs text-bambu-gray">Successful</div>
  182. </div>
  183. <div>
  184. <div className="text-2xl font-bold text-red-400">
  185. {archives.filter(a => {
  186. const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
  187. return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed';
  188. }).length}
  189. </div>
  190. <div className="text-xs text-bambu-gray">Failed</div>
  191. </div>
  192. </div>
  193. </div>
  194. </div>
  195. {/* Selected day details */}
  196. <div className="lg:w-80 bg-bambu-dark rounded-xl p-4">
  197. {selectedDate ? (
  198. <>
  199. <h3 className="text-sm font-medium text-bambu-gray mb-3">
  200. {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', {
  201. weekday: 'long',
  202. month: 'long',
  203. day: 'numeric',
  204. year: 'numeric'
  205. })}
  206. </h3>
  207. {selectedArchives.length > 0 ? (
  208. <div className="calendar-scroll space-y-2 max-h-96 overflow-y-auto">
  209. {selectedArchives.map(archive => {
  210. const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
  211. return (
  212. <button
  213. key={archive.id}
  214. onClick={() => {
  215. setSelectedArchiveId(archive.id);
  216. onArchiveClick?.(archive);
  217. }}
  218. className={`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${
  219. !isHighlighted ? 'hover:bg-bambu-dark-tertiary' : ''
  220. }`}
  221. style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
  222. >
  223. {archive.thumbnail_path ? (
  224. <img
  225. src={api.getArchiveThumbnail(archive.id)}
  226. alt=""
  227. className="w-12 h-12 rounded object-cover"
  228. />
  229. ) : (
  230. <div className="w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center">
  231. <span className="text-xs text-bambu-gray">3MF</span>
  232. </div>
  233. )}
  234. <div className="flex-1 min-w-0">
  235. <p className="text-sm text-white truncate">
  236. {archive.print_name || archive.filename}
  237. </p>
  238. <div className="flex items-center gap-2 text-xs">
  239. <span className={archive.status === 'failed' ? 'text-red-400' : 'text-green-400'}>
  240. {archive.status === 'failed' ? 'Failed' : 'Completed'}
  241. </span>
  242. {archive.filament_color && (
  243. <div className="flex gap-0.5">
  244. {archive.filament_color.split(',').map((color, i) => (
  245. <div
  246. key={i}
  247. className="w-3 h-3 rounded-full border border-black/20"
  248. style={{ backgroundColor: color }}
  249. />
  250. ))}
  251. </div>
  252. )}
  253. </div>
  254. </div>
  255. </button>
  256. );
  257. })}
  258. </div>
  259. ) : (
  260. <p className="text-sm text-bambu-gray">No prints on this day</p>
  261. )}
  262. </>
  263. ) : (
  264. <div className="text-center py-8">
  265. <p className="text-sm text-bambu-gray">Select a day to see prints</p>
  266. </div>
  267. )}
  268. </div>
  269. </div>
  270. );
  271. }