feat: add backup/restore, task indicators, and multiple bug fixes
- Add JSON export/import for all tasks, anyday lists, and projects (Settings > Account > Backup & Restore) - Add merge and replace import modes with confirmation for destructive replace - Add resizable notes sidebar with drag handle on left edge - Add subtask count indicator (indigo pill) on tasks that toggles subtask list - Add note indicator (amber pill) on tasks that toggles inline notes - Show Account ID in Settings > Account as read-only identifier - Fix goal save bug: saved goals matching default text are no longer ignored - Fix calendar week number using Monday within visible range instead of middle day - Fix calendar events not appearing instantly by delaying sync refresh 3s - Grey out URL field when creating events on Google/Outlook calendars v1.12.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5dad5aa492
commit
6574eeb65f
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.11.2",
|
||||
"version": "1.12.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": {
|
||||
|
||||
@ -47,7 +47,7 @@ export async function GET(req: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
if (goal && goal.text && goal.text !== 'your goal of this week' && goal.text !== user?.goalDefaultSentence) {
|
||||
if (goal && goal.text) {
|
||||
return NextResponse.json({ goal: goal.text });
|
||||
}
|
||||
|
||||
|
||||
122
src/app/api/user/export-data/route.ts
Normal file
122
src/app/api/user/export-data/route.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user?.email) {
|
||||
return new NextResponse('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return new NextResponse('User not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch someday lists
|
||||
const somedayLists = await prisma.somedayList.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
order: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch projects
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
color: true,
|
||||
description: true,
|
||||
order: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch all non-deleted tasks (top-level and subtasks)
|
||||
const allTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
markdownContent: true,
|
||||
completed: true,
|
||||
isRolling: true,
|
||||
order: true,
|
||||
dayOfWeek: true,
|
||||
scheduledDate: true,
|
||||
somedayListId: true,
|
||||
originalDate: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
isRecurring: true,
|
||||
recurrenceInterval: true,
|
||||
recurrenceUnit: true,
|
||||
recurrenceTime: true,
|
||||
recurrenceEndDate: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
parentTaskId: true,
|
||||
somedaySlotIndex: true,
|
||||
projectId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a tree: nest subtasks under their parents
|
||||
const taskMap = new Map<string, any>();
|
||||
const topLevelTasks: any[] = [];
|
||||
|
||||
for (const task of allTasks) {
|
||||
taskMap.set(task.id, { ...task, subTasks: [] });
|
||||
}
|
||||
|
||||
for (const task of allTasks) {
|
||||
const taskWithSubs = taskMap.get(task.id)!;
|
||||
if (task.parentTaskId && taskMap.has(task.parentTaskId)) {
|
||||
taskMap.get(task.parentTaskId)!.subTasks.push(taskWithSubs);
|
||||
} else {
|
||||
topLevelTasks.push(taskWithSubs);
|
||||
}
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
exportVersion: 1,
|
||||
exportDate: new Date().toISOString(),
|
||||
somedayLists,
|
||||
projects,
|
||||
tasks: topLevelTasks,
|
||||
};
|
||||
|
||||
const jsonContent = JSON.stringify(exportData, null, 2);
|
||||
|
||||
return new NextResponse(jsonContent, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="weekly_todo_backup_${new Date().toISOString().split('T')[0]}.json"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Export data error:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
215
src/app/api/user/import-data/route.ts
Normal file
215
src/app/api/user/import-data/route.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface ImportTask {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
markdownContent?: string | null;
|
||||
completed?: boolean;
|
||||
isRolling?: boolean;
|
||||
order?: number;
|
||||
dayOfWeek?: number | null;
|
||||
scheduledDate?: string | null;
|
||||
somedayListId?: string | null;
|
||||
originalDate?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
isRecurring?: boolean;
|
||||
recurrenceInterval?: number | null;
|
||||
recurrenceUnit?: string | null;
|
||||
recurrenceTime?: string | null;
|
||||
recurrenceEndDate?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
parentTaskId?: string | null;
|
||||
somedaySlotIndex?: number | null;
|
||||
projectId?: string | null;
|
||||
subTasks?: ImportTask[];
|
||||
}
|
||||
|
||||
interface ImportData {
|
||||
exportVersion: number;
|
||||
somedayLists?: Array<{
|
||||
id?: string;
|
||||
title: string;
|
||||
order?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
projects?: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
description?: string | null;
|
||||
order?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
tasks?: ImportTask[];
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const mode = searchParams.get('mode') || 'merge';
|
||||
|
||||
const body: ImportData = await request.json();
|
||||
|
||||
// Validate structure
|
||||
if (!body.exportVersion || typeof body.exportVersion !== 'number') {
|
||||
return NextResponse.json({ error: 'Invalid export file: missing exportVersion' }, { status: 400 });
|
||||
}
|
||||
|
||||
const somedayLists = body.somedayLists || [];
|
||||
const projects = body.projects || [];
|
||||
const tasks = body.tasks || [];
|
||||
|
||||
// ID mapping: old export ID -> new DB ID
|
||||
const listIdMap = new Map<string, string>();
|
||||
const projectIdMap = new Map<string, string>();
|
||||
const taskIdMap = new Map<string, string>();
|
||||
|
||||
let importedLists = 0;
|
||||
let importedProjects = 0;
|
||||
let importedTasks = 0;
|
||||
|
||||
// In replace mode, delete all existing data first
|
||||
if (mode === 'replace') {
|
||||
await prisma.task.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.somedayList.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.project.deleteMany({ where: { userId: user.id } });
|
||||
}
|
||||
|
||||
// 1. Import someday lists
|
||||
for (const list of somedayLists) {
|
||||
const created = await prisma.somedayList.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title: list.title,
|
||||
order: list.order ?? 0,
|
||||
},
|
||||
});
|
||||
if (list.id) {
|
||||
listIdMap.set(list.id, created.id);
|
||||
}
|
||||
importedLists++;
|
||||
}
|
||||
|
||||
// 2. Import projects
|
||||
for (const project of projects) {
|
||||
const created = await prisma.project.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: project.name,
|
||||
icon: project.icon ?? null,
|
||||
color: project.color ?? null,
|
||||
description: project.description ?? null,
|
||||
order: project.order ?? 0,
|
||||
},
|
||||
});
|
||||
if (project.id) {
|
||||
projectIdMap.set(project.id, created.id);
|
||||
}
|
||||
importedProjects++;
|
||||
}
|
||||
|
||||
// 3. Import tasks (two-pass: parents first, then link subtasks)
|
||||
// Flatten all tasks with their subtasks
|
||||
const flatTasks: Array<{ task: ImportTask; originalParentId: string | null }> = [];
|
||||
|
||||
function flattenTasks(taskList: ImportTask[], parentId: string | null) {
|
||||
for (const task of taskList) {
|
||||
flatTasks.push({ task, originalParentId: parentId });
|
||||
if (task.subTasks && task.subTasks.length > 0) {
|
||||
flattenTasks(task.subTasks, task.id || null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flattenTasks(tasks, null);
|
||||
|
||||
// Pass 1: Create all tasks without parentTaskId
|
||||
for (const { task } of flatTasks) {
|
||||
const resolvedListId = task.somedayListId ? listIdMap.get(task.somedayListId) : null;
|
||||
const resolvedProjectId = task.projectId ? projectIdMap.get(task.projectId) : null;
|
||||
|
||||
const created = await prisma.task.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
title: task.title,
|
||||
description: task.description ?? null,
|
||||
markdownContent: task.markdownContent ?? null,
|
||||
completed: task.completed ?? false,
|
||||
isRolling: task.isRolling ?? false,
|
||||
order: task.order ?? 0,
|
||||
dayOfWeek: task.dayOfWeek ?? null,
|
||||
scheduledDate: task.scheduledDate ? new Date(task.scheduledDate) : null,
|
||||
somedayListId: resolvedListId ?? null,
|
||||
originalDate: task.originalDate ? new Date(task.originalDate) : null,
|
||||
startTime: task.startTime ?? null,
|
||||
endTime: task.endTime ?? null,
|
||||
isRecurring: task.isRecurring ?? false,
|
||||
recurrenceInterval: task.recurrenceInterval ?? null,
|
||||
recurrenceUnit: task.recurrenceUnit ?? null,
|
||||
recurrenceTime: task.recurrenceTime ?? null,
|
||||
recurrenceEndDate: task.recurrenceEndDate ? new Date(task.recurrenceEndDate) : null,
|
||||
somedaySlotIndex: task.somedaySlotIndex ?? null,
|
||||
projectId: resolvedProjectId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (task.id) {
|
||||
taskIdMap.set(task.id, created.id);
|
||||
}
|
||||
importedTasks++;
|
||||
}
|
||||
|
||||
// Pass 2: Link subtasks to their parents
|
||||
for (const { task, originalParentId } of flatTasks) {
|
||||
if (originalParentId && task.id) {
|
||||
const newTaskId = taskIdMap.get(task.id);
|
||||
const newParentId = taskIdMap.get(originalParentId);
|
||||
if (newTaskId && newParentId) {
|
||||
await prisma.task.update({
|
||||
where: { id: newTaskId },
|
||||
data: { parentTaskId: newParentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mode,
|
||||
imported: {
|
||||
somedayLists: importedLists,
|
||||
projects: importedProjects,
|
||||
tasks: importedTasks,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Import data error:', error);
|
||||
const message = error instanceof SyntaxError
|
||||
? 'Invalid JSON file'
|
||||
: 'Import failed. Please check the file format.';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ export async function GET(request: NextRequest) {
|
||||
const user = await (prisma.user as any).findUnique({
|
||||
where: { email: session.user.email },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
timezone: true,
|
||||
|
||||
@ -2848,8 +2848,7 @@ h3 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 500px;
|
||||
max-width: 95vw;
|
||||
max-width: 90vw;
|
||||
height: 100vh;
|
||||
background: white;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@ -23,6 +23,7 @@ export default function CalendarEventModal({
|
||||
const availableCalendars = connections
|
||||
.flatMap(conn => (conn.calendars || []).map((cal: any) => ({
|
||||
...cal,
|
||||
provider: conn.provider,
|
||||
providerName: conn.provider === 'google' ? 'Google Calendar' :
|
||||
conn.provider === 'apple' ? 'Apple Calendar' :
|
||||
'Outlook Calendar'
|
||||
@ -257,16 +258,31 @@ export default function CalendarEventModal({
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
{(() => {
|
||||
const selectedCal = availableCalendars.find((c: any) => c.id === calendarId);
|
||||
const isUrlDisabled = selectedCal && (selectedCal.provider === 'google' || selectedCal.provider === 'outlook');
|
||||
return (
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
value={isUrlDisabled ? '' : url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
placeholder={isUrlDisabled ? 'Not supported by this calendar provider' : 'https://...'}
|
||||
disabled={!!isUrlDisabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
opacity: isUrlDisabled ? 0.5 : 1,
|
||||
background: isUrlDisabled ? '#f5f5f5' : undefined,
|
||||
cursor: isUrlDisabled ? 'not-allowed' : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
|
||||
@ -258,6 +258,20 @@ const translations: Record<string, any> = {
|
||||
alignmentCenter: "Center",
|
||||
alignmentRight: "Right",
|
||||
alignmentTight: "Tight",
|
||||
backupRestore: "Backup & Restore",
|
||||
backupRestoreDesc: "Export all your tasks, anyday lists, and projects as a JSON file. You can edit the file and import it back.",
|
||||
exportAllData: "Export All Data (JSON)",
|
||||
importData: "Import Data",
|
||||
importMode: "Import Mode",
|
||||
importModeMerge: "Merge",
|
||||
importModeMergeDesc: "Add imported data alongside existing tasks",
|
||||
importModeReplace: "Replace",
|
||||
importModeReplaceDesc: "Delete all existing data and replace with imported data",
|
||||
importReplaceWarning: "Warning: This will permanently delete all your current tasks, lists, and projects!",
|
||||
importSelectFile: "Select JSON file...",
|
||||
importButton: "Import",
|
||||
importing: "Importing...",
|
||||
exporting: "Exporting...",
|
||||
},
|
||||
de: {
|
||||
settings: "Einstellungen",
|
||||
@ -323,6 +337,20 @@ const translations: Record<string, any> = {
|
||||
alignmentCenter: "Mitte",
|
||||
alignmentRight: "Rechts",
|
||||
alignmentTight: "Eng",
|
||||
backupRestore: "Sicherung & Wiederherstellung",
|
||||
backupRestoreDesc: "Exportieren Sie alle Aufgaben, Irgendwann-Listen und Projekte als JSON-Datei. Sie können die Datei bearbeiten und wieder importieren.",
|
||||
exportAllData: "Alle Daten exportieren (JSON)",
|
||||
importData: "Daten importieren",
|
||||
importMode: "Import-Modus",
|
||||
importModeMerge: "Zusammenführen",
|
||||
importModeMergeDesc: "Importierte Daten neben bestehenden Aufgaben hinzufügen",
|
||||
importModeReplace: "Ersetzen",
|
||||
importModeReplaceDesc: "Alle bestehenden Daten löschen und durch importierte ersetzen",
|
||||
importReplaceWarning: "Warnung: Dies löscht dauerhaft alle Ihre aktuellen Aufgaben, Listen und Projekte!",
|
||||
importSelectFile: "JSON-Datei auswählen...",
|
||||
importButton: "Importieren",
|
||||
importing: "Importiere...",
|
||||
exporting: "Exportiere...",
|
||||
},
|
||||
};
|
||||
|
||||
@ -401,6 +429,7 @@ function getHourFromSlot(slot: string): number {
|
||||
}
|
||||
|
||||
function getWeekNumber(date: Date): number {
|
||||
// ISO 8601 week number: weeks start on Monday
|
||||
const d = new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
|
||||
);
|
||||
@ -410,6 +439,25 @@ function getWeekNumber(date: Date): number {
|
||||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
// Find the Monday within a visible week range to get the correct CW
|
||||
function getMondayOfVisibleWeek(days: Date[]): Date {
|
||||
// Look for Monday in visible days
|
||||
for (const day of days) {
|
||||
if (day.getDay() === 1) return day;
|
||||
}
|
||||
// If no Monday visible (e.g. 5-day view starting Wed), find nearest Monday
|
||||
if (days.length > 0) {
|
||||
const first = days[0];
|
||||
const dayOfWeek = first.getDay();
|
||||
// Go forward to Monday
|
||||
const daysUntilMon = (1 - dayOfWeek + 7) % 7;
|
||||
if (daysUntilMon <= 6) {
|
||||
return new Date(first.getTime() + daysUntilMon * 86400000);
|
||||
}
|
||||
}
|
||||
return days[Math.floor(days.length / 2)] || new Date();
|
||||
}
|
||||
|
||||
// Check if an event is an all-day event
|
||||
// Defined outside component to avoid stale closure issues in useCallbacks
|
||||
const isAllDayEvent = (event: CalendarEvent): boolean => {
|
||||
@ -1068,8 +1116,9 @@ export default function WeeklyView() {
|
||||
return [...prev, frontendEvent];
|
||||
});
|
||||
}
|
||||
// Also force-refresh from provider to ensure full sync
|
||||
fetchCalendarEvents(true);
|
||||
// Delay the force-refresh to give the provider time to propagate
|
||||
// This prevents overwriting the optimistic update with stale data
|
||||
setTimeout(() => fetchCalendarEvents(true), 3000);
|
||||
} catch (error: any) {
|
||||
console.error("Error saving event:", error);
|
||||
if (error.name === "AbortError") {
|
||||
@ -3752,7 +3801,7 @@ export default function WeeklyView() {
|
||||
) : syncError ? (
|
||||
<AlertCircle size={14} className="text-red-500" />
|
||||
) : null}
|
||||
<span style={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>KW{getWeekNumber((() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })()).toString().padStart(2, "0")}/{(() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })().getFullYear()}</span>
|
||||
<span style={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>KW{getWeekNumber(getMondayOfVisibleWeek(getVisibleDays())).toString().padStart(2, "0")}/{getMondayOfVisibleWeek(getVisibleDays()).getFullYear()}</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Settings + Overflow */}
|
||||
@ -3963,7 +4012,7 @@ export default function WeeklyView() {
|
||||
color: adjustColorForDarkMode(profile.cwColor || "#333333", darkMode),
|
||||
filter: "brightness(var(--weekly-header-brightness, 1))"
|
||||
}}>
|
||||
KW {getWeekNumber((() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })()).toString().padStart(2, "0")}
|
||||
KW {getWeekNumber(getMondayOfVisibleWeek(getVisibleDays())).toString().padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span
|
||||
@ -6399,29 +6448,70 @@ function TaskItem({
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{/* Subtask indicator - toggles subtask list */}
|
||||
{task.subTasks && task.subTasks.length > 0 && !isSubTask && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSubTasksOpen(!isSubTasksOpen);
|
||||
}}
|
||||
className="focus:outline-none flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
title={isSubTasksOpen ? "Collapse subtasks" : "Expand subtasks"}
|
||||
className="focus:outline-none flex-shrink-0"
|
||||
title={isSubTasksOpen ? "Collapse subtasks" : `${task.subTasks.length} subtask${task.subTasks.length > 1 ? "s" : ""}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "2px",
|
||||
padding: "1px 4px",
|
||||
borderRadius: "3px",
|
||||
background: isSubTasksOpen ? "rgba(99, 102, 241, 0.12)" : "rgba(0,0,0,0.05)",
|
||||
color: isSubTasksOpen ? "#6366f1" : "#888",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isSubTasksOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
{task.subTasks.length}
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isSubTasksOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{task.markdownContent && (
|
||||
<span className="task-note-icon flex-shrink-0" data-note={task.markdownContent}>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Note indicator - toggles inline notes */}
|
||||
{task.markdownContent && task.markdownContent.trim().length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsNotesOpen(!isNotesOpen);
|
||||
}}
|
||||
className="focus:outline-none flex-shrink-0"
|
||||
title={isNotesOpen ? "Collapse note" : "Expand note"}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "1px 4px",
|
||||
borderRadius: "3px",
|
||||
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.05)",
|
||||
color: isNotesOpen ? "#f59e0b" : "#888",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
lineHeight: 1,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@ -6937,6 +7027,29 @@ interface NotesSidebarProps {
|
||||
function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(500);
|
||||
const isResizing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
const newWidth = window.innerWidth - e.clientX;
|
||||
setSidebarWidth(Math.max(320, Math.min(newWidth, window.innerWidth * 0.9)));
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
if (isResizing.current) {
|
||||
isResizing.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
};
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||||
@ -6985,7 +7098,26 @@ function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
|
||||
onClick={handleClose}
|
||||
style={{ zIndex: 1999 }}
|
||||
/>
|
||||
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`}>
|
||||
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`} style={{ width: `${sidebarWidth}px` }}>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
isResizing.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '6px',
|
||||
cursor: 'col-resize',
|
||||
zIndex: 10,
|
||||
}}
|
||||
title="Drag to resize"
|
||||
/>
|
||||
<header className="weekly-notes-sidebar-header">
|
||||
<h2 className="weekly-notes-sidebar-title">Notes: {task.title}</h2>
|
||||
<button className="weekly-notes-sidebar-close" onClick={handleClose}>
|
||||
@ -7104,6 +7236,11 @@ function SettingsSidebar({
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [exportStartDate, setExportStartDate] = useState("");
|
||||
const [exportEndDate, setExportEndDate] = useState("");
|
||||
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importMsg, setImportMsg] = useState("");
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isExportingAll, setIsExportingAll] = useState(false);
|
||||
const [passwords, setPasswords] = useState({ new: "", confirm: "" });
|
||||
const [accountMsg, setAccountMsg] = useState("");
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@ -7209,6 +7346,7 @@ function SettingsSidebar({
|
||||
showTaskCheckboxes?: boolean;
|
||||
quoteSourceUrls?: string[];
|
||||
startDayOffset?: number;
|
||||
id?: string;
|
||||
}>({
|
||||
name: "",
|
||||
email: "",
|
||||
@ -7695,6 +7833,81 @@ function SettingsSidebar({
|
||||
window.open("/api/user/export", "_blank");
|
||||
};
|
||||
|
||||
const handleExportAllData = async () => {
|
||||
setIsExportingAll(true);
|
||||
try {
|
||||
const res = await fetch("/api/user/export-data");
|
||||
if (!res.ok) throw new Error("Export failed");
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `weekly_todo_backup_${new Date().toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error("Export error:", e);
|
||||
} finally {
|
||||
setIsExportingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportData = async () => {
|
||||
if (!importFile) return;
|
||||
|
||||
if (importMode === "replace") {
|
||||
const confirmed = confirm(
|
||||
profile.language === "de"
|
||||
? "Sind Sie sicher? Alle bestehenden Aufgaben, Listen und Projekte werden gelöscht und durch die importierten Daten ersetzt."
|
||||
: "Are you sure? All existing tasks, lists, and projects will be deleted and replaced with the imported data."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setIsImporting(true);
|
||||
setImportMsg("");
|
||||
|
||||
try {
|
||||
const text = await importFile.text();
|
||||
JSON.parse(text); // validate JSON
|
||||
|
||||
const res = await fetch(`/api/user/import-data?mode=${importMode}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: text,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setImportMsg(`❌ ${data.error || "Import failed"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { imported } = data;
|
||||
const parts: string[] = [];
|
||||
if (imported.tasks > 0) parts.push(`${imported.tasks} ${profile.language === "de" ? "Aufgaben" : "tasks"}`);
|
||||
if (imported.somedayLists > 0) parts.push(`${imported.somedayLists} ${profile.language === "de" ? "Listen" : "lists"}`);
|
||||
if (imported.projects > 0) parts.push(`${imported.projects} ${profile.language === "de" ? "Projekte" : "projects"}`);
|
||||
|
||||
setImportMsg(`✓ ${profile.language === "de" ? "Importiert" : "Imported"}: ${parts.join(", ")}`);
|
||||
setImportFile(null);
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById("import-file-input") as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = "";
|
||||
|
||||
// Reload to reflect imported data
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (e) {
|
||||
setImportMsg(`❌ ${profile.language === "de" ? "Ungültige JSON-Datei" : "Invalid JSON file"}`);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
@ -10438,6 +10651,43 @@ function SettingsSidebar({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{profile.id && (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{profile.language === "de" ? "Konto-ID" : "Account ID"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.id}
|
||||
readOnly
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
border: "1px solid #eee",
|
||||
borderRadius: "4px",
|
||||
background: "#f5f5f5",
|
||||
color: "#555",
|
||||
fontSize: "0.85rem",
|
||||
fontFamily: "monospace",
|
||||
cursor: "text",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", opacity: 0.7 }}>
|
||||
{profile.language === "de"
|
||||
? "Ihre eindeutige Konto-Kennung"
|
||||
: "Your unique account identifier"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
@ -10668,6 +10918,224 @@ function SettingsSidebar({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Backup & Restore Section */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
paddingTop: "20px",
|
||||
borderTop: "1px solid var(--weekly-border)",
|
||||
}}
|
||||
>
|
||||
<h4
|
||||
style={{
|
||||
marginBottom: "10px",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--weekly-settings-title)",
|
||||
}}
|
||||
>
|
||||
{t.backupRestore}
|
||||
</h4>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--weekly-settings-label)",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
{t.backupRestoreDesc}
|
||||
</p>
|
||||
|
||||
{/* Export All Data */}
|
||||
<button
|
||||
onClick={handleExportAllData}
|
||||
disabled={isExportingAll}
|
||||
className="weekly-auth-button w-full justify-center"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
background: "var(--weekly-settings-item-bg)",
|
||||
border: "1px solid var(--weekly-settings-input-border)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
borderRadius: "4px",
|
||||
fontWeight: 500,
|
||||
cursor: isExportingAll ? "wait" : "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
marginBottom: "15px",
|
||||
opacity: isExportingAll ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isExportingAll ? t.exporting : t.exportAllData}
|
||||
</button>
|
||||
|
||||
{/* Import Section */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
border: "1px solid var(--weekly-border)",
|
||||
borderRadius: "6px",
|
||||
background: "var(--weekly-settings-item-bg)",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "10px",
|
||||
color: "var(--weekly-settings-text)",
|
||||
}}
|
||||
>
|
||||
{t.importData}
|
||||
</label>
|
||||
|
||||
{/* Import Mode Toggle */}
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "6px",
|
||||
color: "var(--weekly-settings-label)",
|
||||
}}
|
||||
>
|
||||
{t.importMode}
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
onClick={() => setImportMode("merge")}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: importMode === "merge"
|
||||
? "2px solid var(--weekly-teal)"
|
||||
: "1px solid var(--weekly-settings-input-border)",
|
||||
background: importMode === "merge"
|
||||
? "rgba(20, 184, 166, 0.1)"
|
||||
: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: importMode === "merge" ? 600 : 400,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<div>{t.importModeMerge}</div>
|
||||
<div style={{ fontSize: "0.75rem", opacity: 0.7, marginTop: "2px" }}>
|
||||
{t.importModeMergeDesc}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setImportMode("replace")}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: importMode === "replace"
|
||||
? "2px solid #ef4444"
|
||||
: "1px solid var(--weekly-settings-input-border)",
|
||||
background: importMode === "replace"
|
||||
? "rgba(239, 68, 68, 0.1)"
|
||||
: "var(--weekly-settings-input-bg)",
|
||||
color: importMode === "replace" ? "#ef4444" : "var(--weekly-settings-text)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: importMode === "replace" ? 600 : 400,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<div>{t.importModeReplace}</div>
|
||||
<div style={{ fontSize: "0.75rem", opacity: 0.7, marginTop: "2px" }}>
|
||||
{t.importModeReplaceDesc}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importMode === "replace" && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
marginBottom: "10px",
|
||||
borderRadius: "4px",
|
||||
background: "rgba(239, 68, 68, 0.08)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
fontSize: "0.8rem",
|
||||
color: "#ef4444",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t.importReplaceWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Input */}
|
||||
<input
|
||||
id="import-file-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={(e) => {
|
||||
setImportFile(e.target.files?.[0] || null);
|
||||
setImportMsg("");
|
||||
}}
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px",
|
||||
border: "1px solid var(--weekly-settings-input-border)",
|
||||
borderRadius: "4px",
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
marginBottom: "10px",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleImportData}
|
||||
disabled={!importFile || isImporting}
|
||||
className="weekly-auth-button w-full justify-center"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
background: !importFile || isImporting
|
||||
? "var(--weekly-settings-input-bg)"
|
||||
: "var(--weekly-teal)",
|
||||
border: "1px solid var(--weekly-settings-input-border)",
|
||||
color: !importFile || isImporting
|
||||
? "var(--weekly-settings-label)"
|
||||
: "#fff",
|
||||
borderRadius: "4px",
|
||||
fontWeight: 600,
|
||||
cursor: !importFile || isImporting ? "not-allowed" : "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
opacity: !importFile || isImporting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isImporting ? t.importing : t.importButton}
|
||||
</button>
|
||||
|
||||
{importMsg && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: importMsg.startsWith("✓")
|
||||
? "#059669"
|
||||
: "#dc2626",
|
||||
}}
|
||||
>
|
||||
{importMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="account-danger-zone"
|
||||
style={{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user