feat: mobile UX overhaul and fix Google OAuth
- Add touch-friendly task actions (tap-to-reveal toolbar) - Add swipe gestures (right=complete, left=delete) - Add compact mobile header with overflow menu - Add FAB + bottom sheet for quick task creation - Add mobile date picker as centered modal - Add touch feedback CSS (scale on press, no sticky hover) - Fix Google sign-in by adding allowDangerousEmailAccountLinking - Fix Google Calendar OAuth using separate 'google-calendar' provider to avoid collision with NextAuth's Account records - Update all API routes to support both provider names v1.11.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
58a35a0c44
commit
b001845890
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
3
src/app/api/someday-lists/external/route.ts
vendored
3
src/app/api/someday-lists/external/route.ts
vendored
@ -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 });
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user