diff --git a/package.json b/package.json index 3b62a02..6950d31 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/api/calendar/google/oauth/route.ts b/src/app/api/calendar/google/oauth/route.ts index 670f21b..ac45006 100644 --- a/src/app/api/calendar/google/oauth/route.ts +++ b/src/app/api/calendar/google/oauth/route.ts @@ -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, diff --git a/src/app/api/someday-lists/external/route.ts b/src/app/api/someday-lists/external/route.ts index 01487c2..d751f0c 100644 --- a/src/app/api/someday-lists/external/route.ts +++ b/src/app/api/someday-lists/external/route.ts @@ -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 }); diff --git a/src/app/api/tasks/import/route.ts b/src/app/api/tasks/import/route.ts index fcd1d13..2f12ad4 100644 --- a/src/app/api/tasks/import/route.ts +++ b/src/app/api/tasks/import/route.ts @@ -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) { diff --git a/src/app/api/tasks/lists/route.ts b/src/app/api/tasks/lists/route.ts index 5aa63a1..d008ed7 100644 --- a/src/app/api/tasks/lists/route.ts +++ b/src/app/api/tasks/lists/route.ts @@ -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) { diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index 2a80e81..a597dca 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -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 }); diff --git a/src/app/globals.css b/src/app/globals.css index 4a5b55f..6b99fb6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; +} diff --git a/src/components/GridTaskBlock.tsx b/src/components/GridTaskBlock.tsx index a066a62..c49931e 100644 --- a/src/components/GridTaskBlock.tsx +++ b/src/components/GridTaskBlock.tsx @@ -69,6 +69,19 @@ export function GridTaskBlock({ const notesRef = useRef(null); const subTaskInputRef = useRef(null); + // Touch: tap-to-reveal actions + const [touchActive, setTouchActive] = useState(false); + const blockRef = useRef(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(null); @@ -164,7 +177,8 @@ export function GridTaskBlock({ return (
{ 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); }} > diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 040af51..d484174 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -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(null); + const fabTextareaRef = useRef(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 */} -
+ {/* Mobile Header */} + {isMobile && ( +
+ {/* Left: Navigation */} +
+ + + + + +
+ + {/* Center: Week info */} +
+ {(isLoading || isSyncing || syncStatus === "syncing") ? ( +
+ ) : syncError ? ( + + ) : null} + KW {getWeekNumber(currentWeekStart).toString().padStart(2, "0")} +
+ + {/* Right: Settings + Overflow */} +
+ +
+ + {showMobileMenu && ( +
setShowMobileMenu(false)}> + + +
+ + +
+ + + +
+
+ Days to show +
+ {[1, 3, 5, 7].map((num) => ( + + ))} +
+
+ {showTimeGrid && ( +
+ Slot duration +
+ {[15, 30, 60].map((d) => ( + + ))} +
+
+ )} +
+ + +
+ +
+ )} +
+
+
+ )} + + {/* Desktop Header: Left, Center, Right */} +
{/* LEFT SECTION: Slot Duration & Days to Show */}
{/* 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 && ( + + )} + + {/* Mobile: Bottom Sheet for task creation */} + {isMobile && showMobileFabSheet && ( + <> +
{ setShowMobileFabSheet(false); setFabTaskTitle(""); }} /> +
+
+