Merge branch 'dev'

This commit is contained in:
mARTin 2026-03-02 18:42:00 +01:00
commit 452349ec43
10 changed files with 618 additions and 18 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.10.5",
"version": "1.11.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -123,15 +123,17 @@ export async function GET(request: NextRequest) {
});
}
// Also store the Google account record for Tasks API access
// Store Google account record for Tasks API access
// Use 'google-calendar' provider to avoid collision with NextAuth's 'google' provider
const existingAccount = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
where: { userId: user.id, provider: { in: ['google-calendar', 'google'] } }
});
if (existingAccount) {
await prisma.account.update({
where: { id: existingAccount.id },
data: {
provider: 'google-calendar',
access_token: tokens.access_token || '',
refresh_token: tokens.refresh_token || existingAccount.refresh_token,
expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null,
@ -144,8 +146,8 @@ export async function GET(request: NextRequest) {
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: user.id,
provider: 'google-calendar',
providerAccountId: `calendar-${user.id}`,
access_token: tokens.access_token || '',
refresh_token: tokens.refresh_token || null,
expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null,

View File

@ -37,7 +37,8 @@ export async function POST(req: NextRequest) {
if (provider === 'google') {
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
where: { userId: user.id, provider: { in: ['google-calendar', 'google'] } },
orderBy: { provider: 'asc' }
});
if (!account || !account.access_token) {
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });

View File

@ -62,7 +62,8 @@ export async function POST(req: NextRequest) {
if (provider === 'google') {
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
where: { userId: user.id, provider: { in: ['google-calendar', 'google'] } },
orderBy: { provider: 'asc' }
});
if (!account || !account.access_token) {

View File

@ -34,7 +34,8 @@ export async function GET(req: NextRequest) {
if (provider === 'google') {
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
where: { userId: user.id, provider: { in: ['google-calendar', 'google'] } },
orderBy: { provider: 'asc' } // 'google-calendar' sorts before 'google'
});
if (!account || !account.access_token) {

View File

@ -53,7 +53,7 @@ export async function GET(req: NextRequest) {
if (hasGoogleTasks) {
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
where: { userId: user.id, provider: { in: ['google-calendar', 'google'] } }
});
if (account?.access_token) {
@ -357,7 +357,7 @@ export async function PATCH(req: NextRequest) {
if (task.externalProvider === 'google' && task.externalListId) {
const account = await prisma.account.findFirst({
where: { userId: task.userId, provider: 'google' }
where: { userId: task.userId, provider: { in: ['google-calendar', 'google'] } }
});
if (account && account.access_token) {
@ -494,7 +494,7 @@ export async function POST(req: NextRequest) {
if (provider === 'google') {
const account = await prisma.account.findFirst({
where: { userId: task.userId, provider: 'google' }
where: { userId: task.userId, provider: { in: ['google-calendar', 'google'] } }
});
if (!account?.access_token) {
return NextResponse.json({ error: 'Google token not available' }, { status: 400 });

View File

@ -3339,3 +3339,235 @@ h3 {
border: 2px solid rgba(255, 255, 255, 0.1);
border-top-color: #60a5fa;
}
/* ============================================
TOUCH DEVICE OVERRIDES (@media pointer: coarse)
============================================ */
@media (pointer: coarse) {
/* Prevent iOS tap highlight */
* { -webkit-tap-highlight-color: transparent; }
/* Disable text selection on interactive task items */
.weekly-task-item,
.time-slot-task {
-webkit-user-select: none;
user-select: none;
}
/* --- Task Actions: tap-to-reveal instead of hover --- */
/* Disable hover reveal on touch devices */
.weekly-task-item:hover .task-actions,
.time-slot-task:hover .task-actions {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
/* Show via .touch-active class (toggled by JS) */
.weekly-task-item.touch-active .task-actions,
.time-slot-task.touch-active .task-actions {
opacity: 1;
pointer-events: auto;
visibility: visible;
}
/* Larger tap targets for action buttons */
.task-action-btn {
min-width: 36px;
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
/* --- Touch feedback on tappable elements --- */
.weekly-btn-icon:active,
.task-action-btn:active,
button:active {
transform: scale(0.92);
transition: transform 0.1s ease;
}
/* Remove sticky hover highlights on touch */
.weekly-btn-icon:hover,
.task-action-btn:hover {
background: transparent;
}
/* --- Swipe action indicators --- */
.task-swipe-container {
position: relative;
overflow: hidden;
}
.task-swipe-indicator {
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
padding: 0 16px;
color: white;
font-weight: 600;
font-size: 0.75rem;
gap: 6px;
pointer-events: none;
transition: opacity 0.15s ease;
}
.task-swipe-indicator.complete {
left: 0;
background: #22c55e;
border-radius: 6px 0 0 6px;
}
.task-swipe-indicator.delete {
right: 0;
background: #ef4444;
border-radius: 0 6px 6px 0;
flex-direction: row-reverse;
}
.task-swipe-content {
transition: transform 0.15s ease;
position: relative;
z-index: 1;
background: inherit;
}
}
/* --- Mobile overflow menu --- */
.mobile-overflow-menu {
position: absolute;
right: 0;
top: 100%;
margin-top: 4px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
z-index: 1000;
padding: 8px;
min-width: 220px;
max-height: 70vh;
overflow-y: auto;
}
.dark .mobile-overflow-menu {
background: #1f2937;
border-color: #374151;
}
.mobile-overflow-menu button,
.mobile-overflow-menu .mobile-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 0.85rem;
color: #374151;
cursor: pointer;
text-align: left;
}
.dark .mobile-overflow-menu button,
.dark .mobile-overflow-menu .mobile-menu-item {
color: #e5e7eb;
}
.mobile-overflow-menu button:active,
.mobile-overflow-menu .mobile-menu-item:active {
background: #f3f4f6;
}
.dark .mobile-overflow-menu button:active,
.dark .mobile-overflow-menu .mobile-menu-item:active {
background: #374151;
}
.mobile-menu-divider {
height: 1px;
background: #e5e7eb;
margin: 4px 8px;
}
.dark .mobile-menu-divider {
background: #374151;
}
/* --- Floating Action Button --- */
.mobile-fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #0ea5e9;
color: white;
border: none;
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.mobile-fab:active {
transform: scale(0.9);
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
}
/* --- Bottom Sheet --- */
.bottom-sheet-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 950;
animation: fadeIn 0.2s ease;
}
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-radius: 16px 16px 0 0;
padding: 20px;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
z-index: 960;
box-shadow: 0 -4px 20px rgba(0,0,0,0.1);
animation: slideUp 0.25s ease;
}
.dark .bottom-sheet {
background: #1f2937;
}
.bottom-sheet-handle {
width: 36px;
height: 4px;
background: #d1d5db;
border-radius: 2px;
margin: 0 auto 16px;
}
.dark .bottom-sheet-handle {
background: #4b5563;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* --- Mobile date picker modal --- */
.mobile-date-picker-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.mobile-date-picker-overlay > * {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
}

View File

@ -69,6 +69,19 @@ export function GridTaskBlock({
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
// Touch: tap-to-reveal actions
const [touchActive, setTouchActive] = useState(false);
const blockRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!touchActive) return;
const handler = (e: Event) => {
if (blockRef.current && !blockRef.current.contains(e.target as Node)) setTouchActive(false);
};
document.addEventListener("touchstart", handler);
document.addEventListener("mousedown", handler);
return () => { document.removeEventListener("touchstart", handler); document.removeEventListener("mousedown", handler); };
}, [touchActive]);
// Resize State
const [isResizing, setIsResizing] = useState(false);
const [resizeHeight, setResizeHeight] = useState<number | null>(null);
@ -164,7 +177,8 @@ export function GridTaskBlock({
return (
<div
className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""}`}
ref={blockRef}
className={`time-slot-task ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${draggedTask?.id === task.id ? "dragging" : ""} ${touchActive ? "touch-active" : ""}`}
style={{
position: "absolute",
top: `${topOffset}px`,
@ -187,6 +201,13 @@ export function GridTaskBlock({
onDragEnd={handleDragEnd}
onClick={(e) => {
e.stopPropagation();
// Touch: toggle action toolbar on tap instead of toggling completion
if (window.matchMedia("(pointer: coarse)").matches && editingTaskId !== task.id) {
const target = e.target as HTMLElement;
if (target.closest(".task-actions") || target.closest("button")) return;
setTouchActive(!touchActive);
return;
}
if (editingTaskId !== task.id) toggleTask(task.id);
}}
>

View File

@ -43,6 +43,8 @@ import {
Undo2,
Redo2,
AlertCircle,
MoreVertical,
Check,
} from "lucide-react";
// Types
@ -554,6 +556,14 @@ export default function WeeklyView() {
const [redoCount, setRedoCount] = useState(0);
const skipSnapshotRef = useRef(false);
// Mobile detection
const [isMobile, setIsMobile] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showMobileFabSheet, setShowMobileFabSheet] = useState(false);
const [fabTaskTitle, setFabTaskTitle] = useState("");
const mobileMenuRef = useRef<HTMLDivElement>(null);
const fabTextareaRef = useRef<HTMLTextAreaElement>(null);
// Moved state definitions to the top
const [showSettings, setShowSettings] = useState(false);
const [activeTab, setActiveTab] = useState<
@ -834,6 +844,37 @@ export default function WeeklyView() {
}
}, []);
// Mobile detection — track viewport width
useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= 768);
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, []);
// Close mobile menu on outside click
useEffect(() => {
if (!showMobileMenu) return;
const handler = (e: MouseEvent) => {
if (mobileMenuRef.current && !mobileMenuRef.current.contains(e.target as Node)) {
setShowMobileMenu(false);
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("touchstart", handler as EventListener);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("touchstart", handler as EventListener);
};
}, [showMobileMenu]);
// Auto-focus FAB bottom sheet textarea
useEffect(() => {
if (showMobileFabSheet && fabTextareaRef.current) {
setTimeout(() => fabTextareaRef.current?.focus(), 100);
}
}, [showMobileFabSheet]);
useEffect(() => {
if (!mounted) return;
localStorage.setItem("weekly-dark-mode", JSON.stringify(darkMode));
@ -3639,8 +3680,128 @@ export default function WeeklyView() {
}}
/>
{/* Refactored Header: Left, Center, Right */}
<header className="group flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200">
{/* Mobile Header */}
{isMobile && (
<header className="flex items-center justify-between w-full px-3 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white" style={{ minHeight: "48px" }}>
{/* Left: Navigation */}
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToPrevWeek} title="Previous Week">
<ChevronsLeft size={16} />
</button>
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToPrevDay} title="Previous Day">
<ChevronLeft size={16} />
</button>
<button className="px-3 py-1 text-xs font-bold text-gray-600 dark:text-gray-300 rounded active:bg-white active:shadow-sm" onClick={goToToday}>
Today
</button>
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToNextDay} title="Next Day">
<ChevronLeft size={16} className="rotate-180" />
</button>
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToNextWeek} title="Next Week">
<ChevronsLeft size={16} className="rotate-180" />
</button>
</div>
{/* Center: Week info */}
<div className="flex items-center gap-1 text-sm font-semibold" style={{ color: darkMode ? "#e5e7eb" : "#333" }}>
{(isLoading || isSyncing || syncStatus === "syncing") ? (
<div className="weekly-spinner" title="Syncing..."></div>
) : syncError ? (
<AlertCircle size={14} className="text-red-500" />
) : null}
<span>KW {getWeekNumber(currentWeekStart).toString().padStart(2, "0")}</span>
</div>
{/* Right: Settings + Overflow */}
<div className="flex items-center gap-1" ref={mobileMenuRef}>
<button className="p-2 rounded-md text-gray-500 active:bg-gray-100" onClick={() => setShowSettings(true)} title="Settings">
<Settings size={18} />
</button>
<div className="relative">
<button className="p-2 rounded-md text-gray-500 active:bg-gray-100" onClick={() => setShowMobileMenu(!showMobileMenu)} title="More">
<MoreVertical size={18} />
</button>
{showMobileMenu && (
<div className="mobile-overflow-menu" onClick={() => setShowMobileMenu(false)}>
<button onClick={() => { setShowDatePicker(true); }}>
<Calendar size={16} /> Jump to date
</button>
<button onClick={() => setIsSearchOpen(true)}>
<Search size={16} /> Search
</button>
<div className="mobile-menu-divider" />
<button onClick={() => {
const now = new Date();
setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` });
}}>
<Plus size={16} /> Add Calendar Event
</button>
<button onClick={() => setIsRecurringTasksOpen(true)}>
<Repeat size={16} /> Recurring Tasks
</button>
<div className="mobile-menu-divider" />
<button onClick={() => { const newVal = !showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); }}>
{showNextTask ? <Play size={16} className="text-teal-600" /> : <Target size={16} />}
{showNextTask ? "Showing Next Task" : "Showing Goal"}
</button>
<button onClick={() => setShowFocusMode(true)}>
<Zap size={16} /> Focus Mode
</button>
<button onClick={() => setDarkMode(!darkMode)}>
{darkMode ? <Sun size={16} className="text-yellow-500" /> : <Moon size={16} />}
{darkMode ? "Light Mode" : "Dark Mode"}
</button>
<div className="mobile-menu-divider" />
<div className="mobile-menu-item" style={{ flexDirection: "column", alignItems: "flex-start", gap: "6px" }}>
<span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>Days to show</span>
<div className="flex items-center gap-1">
{[1, 3, 5, 7].map((num) => (
<button
key={num}
onClick={(e) => { e.stopPropagation(); setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); setShowMobileMenu(false); }}
className={`px-3 py-1 text-xs rounded ${viewDays === num ? "bg-sky-500 text-white font-bold" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{num}
</button>
))}
</div>
</div>
{showTimeGrid && (
<div className="mobile-menu-item" style={{ flexDirection: "column", alignItems: "flex-start", gap: "6px" }}>
<span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>Slot duration</span>
<div className="flex items-center gap-1">
{[15, 30, 60].map((d) => (
<button
key={d}
onClick={(e) => { e.stopPropagation(); setCellDuration(d as CellDuration); saveSetting("cellDuration", d); setShowMobileMenu(false); }}
className={`px-3 py-1 text-xs rounded ${cellDuration === d ? "bg-sky-500 text-white font-bold" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{d}m
</button>
))}
</div>
</div>
)}
<div className="mobile-menu-divider" />
<button onClick={handleUndo} disabled={undoCount === 0} style={undoCount === 0 ? { opacity: 0.3 } : {}}>
<Undo2 size={16} /> Undo
</button>
<button onClick={handleRedo} disabled={redoCount === 0} style={redoCount === 0 ? { opacity: 0.3 } : {}}>
<Redo2 size={16} /> Redo
</button>
<div className="mobile-menu-divider" />
<button onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}>
<RefreshCcw size={16} /> Refresh
</button>
</div>
)}
</div>
</div>
</header>
)}
{/* Desktop Header: Left, Center, Right */}
<header className="group flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : {}}>
{/* LEFT SECTION: Slot Duration & Days to Show */}
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Slot Duration */}
@ -5665,6 +5826,97 @@ export default function WeeklyView() {
lists={importLists}
isLoading={isFetchingLists}
/>
{/* Mobile: Floating Action Button for quick task creation */}
{isMobile && !showMobileFabSheet && !showSettings && !showFocusMode && (
<button
className="mobile-fab"
onClick={() => setShowMobileFabSheet(true)}
title="Add task"
>
<Plus size={28} />
</button>
)}
{/* Mobile: Bottom Sheet for task creation */}
{isMobile && showMobileFabSheet && (
<>
<div className="bottom-sheet-backdrop" onClick={() => { setShowMobileFabSheet(false); setFabTaskTitle(""); }} />
<div className="bottom-sheet">
<div className="bottom-sheet-handle" />
<textarea
ref={fabTextareaRef}
value={fabTaskTitle}
onChange={(e) => setFabTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (fabTaskTitle.trim()) {
// Find today's date and add task
const today = new Date();
const todayStr = formatDateToISO(today);
addTask(today, fabTaskTitle.trim());
setFabTaskTitle("");
setShowMobileFabSheet(false);
}
}
}}
placeholder="What do you need to do?"
rows={2}
style={{
width: "100%",
border: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`,
borderRadius: "12px",
padding: "12px 16px",
fontSize: "1rem",
background: darkMode ? "#111827" : "#f9fafb",
color: darkMode ? "#e5e7eb" : "#333",
outline: "none",
resize: "none",
fontFamily: "inherit",
}}
/>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "12px", gap: "8px" }}>
<button
onClick={() => { setShowMobileFabSheet(false); setFabTaskTitle(""); }}
style={{ padding: "8px 16px", borderRadius: "8px", border: "none", background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#e5e7eb" : "#333", fontSize: "0.85rem", cursor: "pointer" }}
>
Cancel
</button>
<button
onClick={() => {
if (fabTaskTitle.trim()) {
const today = new Date();
addTask(today, fabTaskTitle.trim());
setFabTaskTitle("");
setShowMobileFabSheet(false);
}
}}
style={{ padding: "8px 16px", borderRadius: "8px", border: "none", background: "#0ea5e9", color: "white", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}
>
Add Task
</button>
</div>
</div>
</>
)}
{/* Mobile: Date Picker as centered modal overlay */}
{isMobile && showDatePicker && (
<div className="mobile-date-picker-overlay" onClick={() => setShowDatePicker(false)}>
<div onClick={(e) => e.stopPropagation()}>
<SimpleDatePicker
selected={currentWeekStart}
onSelect={(date) => {
setCurrentWeekStart(getStartOfWeek(date));
setShowDatePicker(false);
}}
onClose={() => setShowDatePicker(false)}
language={language}
/>
</div>
</div>
)}
</div >
);
}
@ -5840,6 +6092,30 @@ function TaskItem({
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
// Touch: tap-to-reveal actions
const [touchActive, setTouchActive] = useState(false);
const taskItemRef = useRef<HTMLLIElement>(null);
// Touch: swipe gesture state
const [swipeX, setSwipeX] = useState(0);
const swipeTouchStart = useRef({ x: 0, y: 0, swiping: false });
// Close touch-active on outside click
useEffect(() => {
if (!touchActive) return;
const handler = (e: Event) => {
if (taskItemRef.current && !taskItemRef.current.contains(e.target as Node)) {
setTouchActive(false);
}
};
document.addEventListener("touchstart", handler);
document.addEventListener("mousedown", handler);
return () => {
document.removeEventListener("touchstart", handler);
document.removeEventListener("mousedown", handler);
};
}, [touchActive]);
const needsSync = task.externalProvider && (
!task.externalId ||
!task.lastSyncedAt ||
@ -5908,11 +6184,19 @@ function TaskItem({
return (
<li
className={`weekly-task-item ${variant} ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${isSomeday ? "relative mx-2" : ""}`}
draggable={!isEditing && !isNotesOpen} // Disable drag when editing
ref={taskItemRef}
className={`weekly-task-item ${variant} ${task.completed && !showTaskCheckboxes ? "completed" : ""} ${isSomeday ? "relative mx-2" : ""} ${touchActive ? "touch-active" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
draggable={!isEditing && !isNotesOpen && swipeX === 0}
onDragStart={(e) => onDragStart(e as unknown as DragEvent, task)}
onDragEnd={onDragEnd}
onClick={(e) => {
// Touch: toggle action toolbar on tap
if (window.matchMedia("(pointer: coarse)").matches && !isEditing) {
const target = e.target as HTMLElement;
if (target.closest(".task-actions") || target.closest("button")) return;
setTouchActive(!touchActive);
return;
}
if ((variant === "minimal" || isSomeday) && !isEditing) {
const target = e.target as HTMLElement;
if (
@ -5924,8 +6208,50 @@ function TaskItem({
onEdit();
}
}}
onTouchStart={(e) => {
const touch = e.touches[0];
swipeTouchStart.current = { x: touch.clientX, y: touch.clientY, swiping: false };
setSwipeX(0);
}}
onTouchMove={(e) => {
const touch = e.touches[0];
const dx = touch.clientX - swipeTouchStart.current.x;
const dy = touch.clientY - swipeTouchStart.current.y;
// Only swipe if horizontal dominant and past 10px threshold
if (!swipeTouchStart.current.swiping && Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.5) {
swipeTouchStart.current.swiping = true;
}
if (swipeTouchStart.current.swiping) {
e.preventDefault();
setSwipeX(dx);
}
}}
onTouchEnd={() => {
if (Math.abs(swipeX) > 80) {
if (swipeX > 0) {
// Swipe right: toggle complete
onToggle();
} else {
// Swipe left: delete
onDelete();
}
}
setSwipeX(0);
swipeTouchStart.current.swiping = false;
}}
>
<div style={{ width: "100%", position: "relative" }}>
{/* Swipe indicators */}
{swipeX > 20 && (
<div className="task-swipe-indicator complete" style={{ width: Math.abs(swipeX) }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
</div>
)}
{swipeX < -20 && (
<div className="task-swipe-indicator delete" style={{ width: Math.abs(swipeX) }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</div>
)}
<div style={{ width: "100%", position: "relative", transform: swipeX !== 0 ? `translateX(${swipeX}px)` : undefined, transition: swipeX === 0 ? "transform 0.2s ease" : "none", background: "inherit" }}>
{/* Visual Indicator for Rolling Tasks */}
{task.isRolling && !task.completed && (
<div className="rolling-icon-indicator" title="Auto-rolling task">

View File

@ -12,6 +12,7 @@ export const authOptions: NextAuthOptions = {
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
allowDangerousEmailAccountLinking: true,
authorization: {
params: {
prompt: "consent",
@ -22,7 +23,8 @@ export const authOptions: NextAuthOptions = {
}),
AppleProvider({
clientId: process.env.APPLE_ID || "",
clientSecret: process.env.APPLE_SECRET || ""
clientSecret: process.env.APPLE_SECRET || "",
allowDangerousEmailAccountLinking: true,
}),
CredentialsProvider({
name: "credentials",
@ -77,6 +79,20 @@ export const authOptions: NextAuthOptions = {
error: "/auth/login",
},
callbacks: {
async signIn({ user, account }) {
// For OAuth providers: ensure user ID is set on the token
// allowDangerousEmailAccountLinking handles auto-linking
if (account?.provider !== "credentials" && user.email) {
const existingUser = await prisma.user.findUnique({
where: { email: user.email },
});
if (existingUser) {
// Ensure the JWT gets the correct DB user ID
user.id = existingUser.id;
}
}
return true;
},
async jwt({ token, user }) {
if (user) {
token.id = user.id;