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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
export interface Counter {
|
||||
id: number;
|
||||
name: string;
|
||||
value: number;
|
||||
image_path: string | null;
|
||||
group_id: number | null;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
counter: Counter;
|
||||
onIncrement: (id: number) => void;
|
||||
onDecrement: (id: number) => void;
|
||||
onEdit: (counter: Counter) => void;
|
||||
onHistory: (id: number) => void;
|
||||
onPrefetch?: (id: number) => void;
|
||||
editMode?: boolean;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function CounterCard({ counter, onIncrement, onDecrement, onEdit, onHistory, onPrefetch, editMode, dragHandleProps }: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className={`bg-ctp-surface0 rounded-2xl shadow-sm ring-1 ring-ctp-surface1 overflow-hidden flex flex-col select-none transition-shadow hover:shadow-lg hover:shadow-ctp-crust${dragHandleProps ? ' cursor-grab active:cursor-grabbing' : ''}`}
|
||||
>
|
||||
|
||||
{/* Title area — click opens edit modal */}
|
||||
<button
|
||||
onClick={() => !editMode && onEdit(counter)}
|
||||
className={`w-full flex items-center justify-center px-4 pt-3 pb-2 h-16 transition-colors ${!editMode ? 'hover:bg-ctp-surface1' : ''}`}
|
||||
title={editMode ? undefined : 'Edit counter'}
|
||||
>
|
||||
<h3 className="font-bold text-ctp-text text-lg leading-tight line-clamp-2 text-center">
|
||||
{counter.name}
|
||||
</h3>
|
||||
</button>
|
||||
|
||||
{/* Middle: image — click opens history modal */}
|
||||
<button
|
||||
onClick={() => !editMode && onHistory(counter.id)}
|
||||
onMouseEnter={() => !editMode && onPrefetch?.(counter.id)}
|
||||
className={`w-full flex-1 flex items-center justify-center transition-colors ${!editMode ? 'hover:bg-ctp-surface1' : ''}`}
|
||||
title={editMode ? undefined : 'View history'}
|
||||
>
|
||||
{counter.image_path ? (
|
||||
<div className="w-full bg-ctp-surface0 flex items-center justify-center overflow-hidden" style={{ minHeight: '6rem', maxHeight: '9rem' }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`/api/uploads/${counter.image_path}`}
|
||||
alt={counter.name}
|
||||
className="max-w-full max-h-36 object-contain py-2"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex items-center justify-center" style={{ minHeight: '4rem' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-ctp-overlay0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Counter controls */}
|
||||
<div className="flex items-center justify-center px-3 py-3 mt-auto">
|
||||
<div className="flex items-center bg-ctp-surface1 rounded-2xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => onDecrement(counter.id)}
|
||||
disabled={counter.value <= 0}
|
||||
aria-label="Decrement"
|
||||
className="w-11 h-11 flex items-center justify-center text-ctp-red hover:bg-ctp-red/20 active:bg-ctp-red/30 active:scale-95 transition-all disabled:opacity-25 disabled:pointer-events-none"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-2xl font-extrabold w-14 text-center tabular-nums text-ctp-text tracking-tight select-none">
|
||||
{counter.value}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onIncrement(counter.id)}
|
||||
aria-label="Increment"
|
||||
className="w-11 h-11 flex items-center justify-center text-ctp-green hover:bg-ctp-green/20 active:bg-ctp-green/30 active:scale-95 transition-all"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Counter } from './CounterCard';
|
||||
import HistoryChart, { DayValue } from './HistoryChart';
|
||||
import CalendarView, { DayCounter } from './CalendarView';
|
||||
|
||||
export interface HistoryData {
|
||||
counter: Counter;
|
||||
dailyActivity: DayValue[];
|
||||
allTimeTotal: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
counterId: number | null;
|
||||
cache?: Map<number, HistoryData>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function toLocalDateStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function computeCurrentStreak(dateSet: Set<string>): number {
|
||||
const today = new Date();
|
||||
let streak = 0;
|
||||
const startOffset = dateSet.has(toLocalDateStr(today)) ? 0 : 1;
|
||||
for (let i = startOffset; i < 400; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
if (dateSet.has(toLocalDateStr(d))) streak++;
|
||||
else break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
function computeLongestStreak(sortedDates: string[]): number {
|
||||
if (sortedDates.length === 0) return 0;
|
||||
let best = 1, cur = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const prev = new Date(sortedDates[i - 1] + 'T00:00:00');
|
||||
const curr = new Date(sortedDates[i] + 'T00:00:00');
|
||||
const diff = (curr.getTime() - prev.getTime()) / 86400000;
|
||||
if (diff === 1) { cur++; if (cur > best) best = cur; }
|
||||
else cur = 1;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export default function CounterDetailModal({ counterId, cache, onClose }: Props) {
|
||||
const [data, setData] = useState<HistoryData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!counterId) return;
|
||||
const cached = cache?.get(counterId);
|
||||
if (cached) { setData(cached); setLoading(false); return; }
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
fetch(`/api/counters/${counterId}/history`)
|
||||
.then(r => r.json())
|
||||
.then((d: HistoryData) => { cache?.set(counterId, d); setData(d); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, [counterId, cache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!counterId) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [counterId, onClose]);
|
||||
|
||||
if (!counterId) return null;
|
||||
|
||||
const dateSet = new Set(data?.dailyActivity.map(d => d.date) ?? []);
|
||||
const sortedDates = [...dateSet].sort();
|
||||
const currentStreak = computeCurrentStreak(dateSet);
|
||||
const longestStreak = computeLongestStreak(sortedDates);
|
||||
|
||||
const calendarData: DayCounter[] = (data?.dailyActivity ?? []).map(d => ({
|
||||
date: d.date,
|
||||
counter_id: data!.counter.id,
|
||||
counter_name: data!.counter.name,
|
||||
image_path: data!.counter.image_path,
|
||||
increments: d.value,
|
||||
}));
|
||||
|
||||
const stats = data ? [
|
||||
{ label: 'Current value', value: data.counter.value.toLocaleString() },
|
||||
{ label: 'All-time net', value: data.allTimeTotal >= 0 ? `+${data.allTimeTotal}` : String(data.allTimeTotal) },
|
||||
{ label: 'Current streak', value: `${currentStreak}d` },
|
||||
{ label: 'Longest streak', value: `${longestStreak}d` },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-ctp-mantle rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-ctp-surface1 shrink-0">
|
||||
{data?.counter.image_path && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/uploads/${data.counter.image_path}`}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded-lg object-cover bg-ctp-surface0 shrink-0"
|
||||
/>
|
||||
)}
|
||||
<h2 className="text-lg font-bold text-ctp-text flex-1 truncate">
|
||||
{data?.counter.name ?? '…'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ctp-overlay1 hover:text-ctp-text transition-colors shrink-0"
|
||||
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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<p className="text-ctp-overlay1 animate-pulse">Loading…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && (
|
||||
<>
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{stats.map(({ label, value }) => (
|
||||
<div key={label} className="bg-ctp-base rounded-xl p-3 text-center">
|
||||
<p className="text-xl font-extrabold text-ctp-mauve tabular-nums">{value}</p>
|
||||
<p className="text-xs text-ctp-subtext1 mt-0.5">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.dailyActivity.length === 0 ? (
|
||||
<div className="text-center py-10 space-y-1">
|
||||
<p className="text-ctp-overlay1">No activity in the last year.</p>
|
||||
<p className="text-ctp-overlay0 text-sm">Start tapping + to see history here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 90-day bar chart */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-ctp-text mb-3">Last 90 Days</h3>
|
||||
<HistoryChart data={data.dailyActivity} days={90} />
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-ctp-text mb-4">Activity Calendar</h3>
|
||||
<CalendarView dailyCounters={calendarData} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Counter } from './CounterCard';
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: Counter | null;
|
||||
groups: Group[];
|
||||
onClose: () => void;
|
||||
onSave: (data: {
|
||||
name: string;
|
||||
value: number;
|
||||
group_id: number | null;
|
||||
image_path: string | null;
|
||||
}) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function CounterModal({ open, initial, groups, onClose, onSave, onDelete }: Props) {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const deleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [value, setValue] = useState(0);
|
||||
const [groupId, setGroupId] = useState<number | null>(null);
|
||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Revoke blob URLs when previewUrl changes (prevents memory leaks)
|
||||
useEffect(() => {
|
||||
const url = previewUrl;
|
||||
return () => { if (url?.startsWith('blob:')) URL.revokeObjectURL(url); };
|
||||
}, [previewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initial?.name ?? '');
|
||||
setValue(initial?.value ?? 0);
|
||||
setGroupId(initial?.group_id ?? null);
|
||||
setImagePath(initial?.image_path ?? null);
|
||||
setPreviewUrl(initial?.image_path ? `/api/uploads/${initial.image_path}` : null);
|
||||
setError('');
|
||||
setConfirmDelete(false);
|
||||
if (deleteTimer.current) clearTimeout(deleteTimer.current);
|
||||
setTimeout(() => nameRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const body = await res.json();
|
||||
setError(body.error ?? 'Upload failed');
|
||||
return;
|
||||
}
|
||||
const { filename } = await res.json();
|
||||
setImagePath(filename);
|
||||
} catch {
|
||||
setError('Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) { setError('Name is required'); return; }
|
||||
if (uploading) { setError('Please wait for the image to finish uploading'); return; }
|
||||
onSave({ name: name.trim(), value, group_id: groupId, image_path: imagePath });
|
||||
}
|
||||
|
||||
function handleBackdrop(e: React.MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div className="bg-ctp-mantle rounded-2xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-ctp-surface1">
|
||||
<h2 className="text-lg font-semibold text-ctp-text">
|
||||
{initial ? 'Edit Counter' : 'New Counter'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-ctp-overlay0 hover:text-ctp-text text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
|
||||
{error && (
|
||||
<p className="text-sm text-ctp-red bg-ctp-red/10 rounded-lg px-3 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Name</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
|
||||
placeholder="Counter name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Initial value */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">
|
||||
{initial ? 'Value' : 'Initial Value'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={e => setValue(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Group</label>
|
||||
<select
|
||||
value={groupId ?? ''}
|
||||
onChange={e => setGroupId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
|
||||
>
|
||||
<option value="">— No group —</option>
|
||||
{groups.map(g => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Image upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ctp-subtext1 mb-1">Photo</label>
|
||||
{previewUrl && (
|
||||
<div className="relative w-full mb-2 rounded-lg overflow-hidden bg-ctp-crust flex items-center justify-center" style={{ minHeight: '8rem', maxHeight: '12rem' }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={previewUrl} alt="Preview" className="max-w-full max-h-48 object-contain py-2" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPreviewUrl(null); setImagePath(null); }}
|
||||
className="absolute top-1 right-1 bg-ctp-crust/80 text-ctp-text rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-ctp-surface0"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<label className={`flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg border border-dashed text-sm font-medium transition-colors ${
|
||||
uploading
|
||||
? 'border-ctp-surface1 text-ctp-overlay0 cursor-wait'
|
||||
: 'border-ctp-surface2 text-ctp-subtext1 hover:border-ctp-mauve hover:text-ctp-mauve hover:bg-ctp-mauve/5'
|
||||
}`}>
|
||||
<input type="file" accept="image/*" onChange={handleFileChange} className="hidden" disabled={uploading} />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
{uploading ? 'Uploading…' : previewUrl ? 'Replace photo' : 'Upload photo'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
{/* Delete — only shown when editing an existing counter */}
|
||||
{initial && onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
if (deleteTimer.current) clearTimeout(deleteTimer.current);
|
||||
onDelete(initial.id);
|
||||
onClose();
|
||||
} else {
|
||||
setConfirmDelete(true);
|
||||
deleteTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
|
||||
}
|
||||
}}
|
||||
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
|
||||
confirmDelete
|
||||
? 'bg-ctp-red hover:bg-ctp-maroon text-ctp-base'
|
||||
: 'text-ctp-red hover:bg-ctp-red/10 border border-ctp-red/30'
|
||||
}`}
|
||||
>
|
||||
{confirmDelete ? 'Confirm delete' : 'Delete counter'}
|
||||
</button>
|
||||
) : <span />}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-ctp-surface1 text-ctp-subtext1 hover:bg-ctp-surface0 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-ctp-mauve hover:bg-ctp-lavender disabled:opacity-50 text-ctp-base font-medium transition-colors"
|
||||
>
|
||||
{initial ? 'Save Changes' : 'Create Counter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import SortableCounter from './SortableCounter';
|
||||
import { Counter } from './CounterCard';
|
||||
import { Group } from './CounterModal';
|
||||
|
||||
interface Props {
|
||||
group: Group | null; // null = ungrouped section
|
||||
counters: Counter[];
|
||||
onIncrement: (id: number) => void;
|
||||
onDecrement: (id: number) => void;
|
||||
onEdit: (counter: Counter) => void;
|
||||
onHistory: (id: number) => void;
|
||||
onPrefetch?: (id: number) => void;
|
||||
editMode: boolean;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
onRenameGroup?: (group: Group) => void;
|
||||
onDeleteGroup?: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function GroupSection({
|
||||
group,
|
||||
counters,
|
||||
onIncrement,
|
||||
onDecrement,
|
||||
onEdit,
|
||||
onHistory,
|
||||
onPrefetch,
|
||||
editMode,
|
||||
dragHandleProps,
|
||||
onRenameGroup,
|
||||
onDeleteGroup,
|
||||
}: Props) {
|
||||
const droppableId = group ? `group-${group.id}` : 'ungrouped';
|
||||
const { setNodeRef, isOver } = useDroppable({ id: droppableId, data: { groupId: group?.id ?? null } });
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [newName, setNewName] = useState(group?.name ?? '');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
function commitRename() {
|
||||
if (group && newName.trim() && newName.trim() !== group.name) {
|
||||
onRenameGroup?.({ ...group, name: newName.trim() });
|
||||
}
|
||||
setEditingName(false);
|
||||
}
|
||||
|
||||
const isEmpty = counters.length === 0;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{group && editMode && dragHandleProps && (
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="cursor-grab active:cursor-grabbing text-ctp-overlay0 hover:text-ctp-subtext1 transition-colors shrink-0"
|
||||
title="Drag to reorder group"
|
||||
>
|
||||
<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">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{group ? (
|
||||
editingName ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
className="text-lg font-bold bg-transparent border-b-2 border-ctp-mauve outline-none text-ctp-text"
|
||||
/>
|
||||
) : (
|
||||
editMode ? (
|
||||
<h2
|
||||
className="text-lg font-bold text-ctp-text cursor-pointer hover:text-ctp-mauve transition-colors"
|
||||
title="Click to rename"
|
||||
onClick={() => { setNewName(group.name); setEditingName(true); }}
|
||||
>
|
||||
{group.name}
|
||||
</h2>
|
||||
) : (
|
||||
<h2 className="text-lg font-bold text-ctp-text">{group.name}</h2>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<h2 className="text-lg font-bold text-ctp-overlay1">Ungrouped</h2>
|
||||
)}
|
||||
|
||||
{group && editMode && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirmDelete) { onDeleteGroup?.(group.id); }
|
||||
else {
|
||||
setConfirmDelete(true);
|
||||
setTimeout(() => setConfirmDelete(false), 3000);
|
||||
}
|
||||
}}
|
||||
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||
confirmDelete
|
||||
? 'bg-ctp-red text-ctp-base'
|
||||
: 'bg-ctp-surface0 text-ctp-overlay1 hover:bg-ctp-red/10 hover:text-ctp-red'
|
||||
}`}
|
||||
>
|
||||
{confirmDelete ? 'Sure?' : 'Delete group'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 min-h-[80px] rounded-2xl transition-colors ${
|
||||
isOver ? 'bg-ctp-mauve/10 ring-2 ring-ctp-mauve' : ''
|
||||
} ${isEmpty ? 'border-2 border-dashed border-ctp-surface1 p-4' : ''}`}
|
||||
>
|
||||
<SortableContext items={counters.map(c => c.id)} strategy={rectSortingStrategy}>
|
||||
{counters.map(counter => (
|
||||
<SortableCounter
|
||||
key={counter.id}
|
||||
counter={counter}
|
||||
onIncrement={onIncrement}
|
||||
onDecrement={onDecrement}
|
||||
onEdit={onEdit}
|
||||
onHistory={onHistory}
|
||||
onPrefetch={onPrefetch}
|
||||
editMode={editMode}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{isEmpty && (
|
||||
<p className="col-span-full text-center text-sm text-ctp-overlay0 self-center">
|
||||
Drop counters here
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface DayValue {
|
||||
date: string; // YYYY-MM-DD
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: DayValue[];
|
||||
days?: number;
|
||||
}
|
||||
|
||||
function toLocalDateStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
export default function HistoryChart({ data, days = 90 }: Props) {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
|
||||
const byDate = new Map(data.map(d => [d.date, d.value]));
|
||||
const today = new Date();
|
||||
|
||||
const filled = Array.from({ length: days }, (_, i) => {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - (days - 1 - i));
|
||||
const date = toLocalDateStr(d);
|
||||
return { date, value: byDate.get(date) ?? 0, month: d.getMonth() };
|
||||
});
|
||||
|
||||
const maxVal = Math.max(...filled.map(d => d.value), 1);
|
||||
const CHART_H = 40;
|
||||
const BAR_UNIT = 100 / days;
|
||||
|
||||
// Show a month label whenever the month changes
|
||||
const monthLabels: { label: string; pct: number }[] = [];
|
||||
filled.forEach((d, i) => {
|
||||
if (i === 0 || d.month !== filled[i - 1].month) {
|
||||
monthLabels.push({ label: MONTH_ABBR[d.month], pct: (i / days) * 100 });
|
||||
}
|
||||
});
|
||||
|
||||
const hoveredDay = hovered !== null ? filled[hovered] : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
{/* Tooltip */}
|
||||
{hoveredDay && hovered !== null && (
|
||||
<div
|
||||
className="absolute bottom-0 mb-1 text-xs bg-ctp-surface1 text-ctp-text rounded-lg px-2 py-1 pointer-events-none shadow border border-ctp-surface2 z-10 whitespace-nowrap -translate-x-1/2"
|
||||
style={{ left: `${Math.min(92, Math.max(8, ((hovered + 0.5) / days) * 100))}%` }}
|
||||
>
|
||||
<span className="font-bold text-ctp-mauve">
|
||||
{hoveredDay.value > 0 ? `+${hoveredDay.value}` : '–'}
|
||||
</span>
|
||||
<span className="text-ctp-subtext1 ml-1.5">{hoveredDay.date}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<svg
|
||||
viewBox={`0 0 100 ${CHART_H}`}
|
||||
className="w-full h-20"
|
||||
preserveAspectRatio="none"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{filled.map((d, i) => {
|
||||
const barH = d.value > 0 ? Math.max(1.5, (d.value / maxVal) * (CHART_H - 2)) : 1;
|
||||
const x = i * BAR_UNIT + BAR_UNIT * 0.1;
|
||||
const w = BAR_UNIT * 0.8;
|
||||
const y = d.value > 0 ? CHART_H - barH : CHART_H - 1;
|
||||
return (
|
||||
<rect
|
||||
key={d.date}
|
||||
x={x} y={y}
|
||||
width={w} height={barH}
|
||||
rx={0.4}
|
||||
className={d.value > 0 ? 'fill-ctp-mauve' : 'fill-ctp-surface1'}
|
||||
style={{ opacity: hovered === i ? 0.6 : 1 }}
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Month labels */}
|
||||
<div className="relative h-4 mt-0.5 select-none">
|
||||
{monthLabels.map(({ label, pct }) => (
|
||||
<span
|
||||
key={`${label}-${pct}`}
|
||||
className="absolute text-ctp-overlay0 leading-none pointer-events-none"
|
||||
style={{ left: `${pct}%`, fontSize: '10px' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import CounterCard, { Counter } from './CounterCard';
|
||||
|
||||
interface Props {
|
||||
counter: Counter;
|
||||
onIncrement: (id: number) => void;
|
||||
onDecrement: (id: number) => void;
|
||||
onEdit: (counter: Counter) => void;
|
||||
onHistory: (id: number) => void;
|
||||
onPrefetch?: (id: number) => void;
|
||||
editMode: boolean;
|
||||
}
|
||||
|
||||
export default function SortableCounter({ counter, editMode, ...handlers }: Props) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: counter.id,
|
||||
data: { type: 'counter', groupId: counter.group_id },
|
||||
disabled: !editMode,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<CounterCard
|
||||
{...handlers}
|
||||
counter={counter}
|
||||
editMode={editMode}
|
||||
dragHandleProps={editMode ? { ...attributes, ...listeners } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import GroupSection from './GroupSection';
|
||||
import { Counter } from './CounterCard';
|
||||
import { Group } from './CounterModal';
|
||||
|
||||
interface Props {
|
||||
group: Group;
|
||||
counters: Counter[];
|
||||
onIncrement: (id: number) => void;
|
||||
onDecrement: (id: number) => void;
|
||||
onEdit: (counter: Counter) => void;
|
||||
onHistory: (id: number) => void;
|
||||
onPrefetch?: (id: number) => void;
|
||||
editMode: boolean;
|
||||
onRenameGroup: (group: Group) => void;
|
||||
onDeleteGroup: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function SortableGroupSection({ group, editMode, ...props }: Props) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: `grp-${group.id}`,
|
||||
data: { type: 'group' },
|
||||
disabled: !editMode,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<GroupSection
|
||||
group={group}
|
||||
editMode={editMode}
|
||||
dragHandleProps={editMode ? { ...attributes, ...listeners } : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user