Web app ready
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface DayCounter {
|
||||
date: string; // YYYY-MM-DD
|
||||
counter_id: number;
|
||||
counter_name: string;
|
||||
image_path: string | null;
|
||||
increments: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dailyCounters: DayCounter[];
|
||||
}
|
||||
|
||||
// Mon-first: 0=Mon … 6=Sun
|
||||
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const MONTH_NAMES = [
|
||||
'January','February','March','April','May','June',
|
||||
'July','August','September','October','November','December',
|
||||
];
|
||||
|
||||
function pad(n: number) { return String(n).padStart(2, '0'); }
|
||||
function toDateStr(y: number, m: number, d: number) {
|
||||
return `${y}-${pad(m + 1)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
// Returns 0 (Mon) … 6 (Sun) for a given date
|
||||
function mondayDow(year: number, month: number, day: number) {
|
||||
const dow = new Date(year, month, day).getDay(); // 0=Sun…6=Sat
|
||||
return (dow + 6) % 7;
|
||||
}
|
||||
|
||||
export default function CalendarView({ dailyCounters }: Props) {
|
||||
const now = new Date();
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth());
|
||||
const [selectedDay, setSelectedDay] = useState<{ date: string; counters: DayCounter[] } | null>(null);
|
||||
|
||||
// Close modal on Escape
|
||||
useEffect(() => {
|
||||
if (!selectedDay) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setSelectedDay(null); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [selectedDay]);
|
||||
|
||||
const byDate = new Map<string, DayCounter[]>();
|
||||
for (const dc of dailyCounters) {
|
||||
if (!byDate.has(dc.date)) byDate.set(dc.date, []);
|
||||
byDate.get(dc.date)!.push(dc);
|
||||
}
|
||||
|
||||
const todayStr = toDateStr(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const isCurrentMonth = viewYear === now.getFullYear() && viewMonth === now.getMonth();
|
||||
|
||||
function prev() {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); }
|
||||
else setViewMonth(m => m - 1);
|
||||
}
|
||||
function next() {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); }
|
||||
else setViewMonth(m => m + 1);
|
||||
}
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const firstOffset = mondayDow(viewYear, viewMonth, 1); // blank cells before day 1
|
||||
const totalCells = Math.ceil((daysInMonth + firstOffset) / 7) * 7;
|
||||
|
||||
const cells = Array.from({ length: totalCells }, (_, i) => {
|
||||
const dayNum = i - firstOffset + 1;
|
||||
if (dayNum < 1 || dayNum > daysInMonth) return null;
|
||||
const date = toDateStr(viewYear, viewMonth, dayNum);
|
||||
return { dayNum, date, counters: byDate.get(date) ?? [] };
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={prev}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-ctp-subtext1 hover:bg-ctp-surface0 hover:text-ctp-text transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-ctp-text">
|
||||
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||
</span>
|
||||
{!isCurrentMonth && (
|
||||
<button
|
||||
onClick={() => { setViewYear(now.getFullYear()); setViewMonth(now.getMonth()); }}
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-ctp-surface0 text-ctp-subtext1 hover:bg-ctp-surface1 hover:text-ctp-text transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={isCurrentMonth}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-ctp-subtext1 hover:bg-ctp-surface0 hover:text-ctp-text transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
Next
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day-of-week header */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{DOW.map(d => (
|
||||
<div key={d} className="text-center text-ctp-overlay0 font-medium" style={{ fontSize: '10px' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7 gap-px bg-ctp-surface1 rounded-xl overflow-hidden border border-ctp-surface1">
|
||||
{cells.map((cell, i) =>
|
||||
cell ? (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedDay({ date: cell.date, counters: cell.counters })}
|
||||
className={`bg-ctp-base min-h-[4.5rem] p-1 flex flex-col gap-0.5 cursor-pointer hover:bg-ctp-surface0 transition-colors ${
|
||||
cell.date === todayStr ? 'ring-1 ring-inset ring-ctp-mauve' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs font-semibold leading-none mb-0.5 ${
|
||||
cell.date === todayStr
|
||||
? 'text-ctp-mauve'
|
||||
: cell.counters.length > 0
|
||||
? 'text-ctp-text'
|
||||
: 'text-ctp-overlay1'
|
||||
}`}>
|
||||
{cell.dayNum}
|
||||
</span>
|
||||
|
||||
{cell.counters.slice(0, 3).map(c => (
|
||||
<div
|
||||
key={c.counter_id}
|
||||
className="flex items-center gap-0.5 bg-ctp-surface0 rounded px-1 overflow-hidden"
|
||||
title={`${c.counter_name}: +${c.increments}`}
|
||||
>
|
||||
<span className="text-ctp-subtext1 truncate leading-tight" style={{ fontSize: '9px' }}>
|
||||
{c.counter_name}
|
||||
</span>
|
||||
<span className="text-ctp-mauve font-bold shrink-0 leading-tight" style={{ fontSize: '9px' }}>
|
||||
+{c.increments}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{cell.counters.length > 3 && (
|
||||
<span className="text-ctp-overlay0 leading-tight" style={{ fontSize: '9px' }}>
|
||||
+{cell.counters.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="bg-ctp-mantle min-h-[4.5rem]" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day detail modal */}
|
||||
{selectedDay && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60"
|
||||
onClick={() => setSelectedDay(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-ctp-surface0 rounded-2xl shadow-xl w-full max-w-sm p-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="font-bold text-ctp-text text-lg">
|
||||
{new Date(selectedDay.date + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long', month: 'long', day: 'numeric',
|
||||
})}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedDay(null)}
|
||||
className="text-ctp-overlay1 hover:text-ctp-text transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Counter list */}
|
||||
{selectedDay.counters.length === 0 ? (
|
||||
<p className="text-center text-ctp-overlay1 py-4">No activity on this day.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedDay.counters.map(c => (
|
||||
<div key={c.counter_id} className="flex items-center gap-3 bg-ctp-base rounded-xl p-3">
|
||||
{c.image_path ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/uploads/${c.image_path}`}
|
||||
alt=""
|
||||
className="w-12 h-12 rounded-lg object-cover shrink-0 bg-ctp-surface1"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-ctp-surface1 shrink-0 flex items-center justify-center text-ctp-overlay0 text-xl">
|
||||
#
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-ctp-text truncate">{c.counter_name}</p>
|
||||
<p className="text-ctp-mauve font-bold text-lg leading-tight">+{c.increments}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total */}
|
||||
{selectedDay.counters.length > 1 && (
|
||||
<p className="mt-4 text-right text-sm text-ctp-subtext1">
|
||||
Total: <span className="text-ctp-mauve font-bold">
|
||||
+{selectedDay.counters.reduce((s, c) => s + c.increments, 0)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user