feat: tasks app, security hardening, mobile fixes, iOS app shell
- Custom SQLite task manager replacing TickTick wrapper - 73 tasks migrated from TickTick across 15 projects - RRULE recurrence engine with lazy materialization - Dashboard tasks widget (desktop sidebar + mobile card) - Tasks page with project tabs, add/edit/complete/delete - Security: locked ports to localhost, removed old containers - Gitea Actions runner configured and all 3 CI jobs passing - Fixed mobile overflow on dashboard cards - iOS Capacitor app shell (Second Brain) - Frontend/backend guide docs for adding new services - TickTick Google Calendar sync re-authorized Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
730
frontend-v2/src/routes/(app)/tasks/+page.svelte
Normal file
730
frontend-v2/src/routes/(app)/tasks/+page.svelte
Normal file
@@ -0,0 +1,730 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
isInbox?: boolean;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
projectId: string;
|
||||
_projectName: string;
|
||||
_projectId: string;
|
||||
dueDate?: string;
|
||||
startDate?: string;
|
||||
isAllDay?: boolean;
|
||||
priority: number;
|
||||
status: number;
|
||||
repeatFlag?: string;
|
||||
}
|
||||
|
||||
type TabType = 'today' | 'all' | 'completed' | 'project';
|
||||
|
||||
let loading = $state(true);
|
||||
let activeTab = $state<TabType>('today');
|
||||
let tasks = $state<Task[]>([]);
|
||||
let todayTasks = $state<Task[]>([]);
|
||||
let overdueTasks = $state<Task[]>([]);
|
||||
let projects = $state<Project[]>([]);
|
||||
let selectedProjectId = $state('');
|
||||
let completing = $state<Set<string>>(new Set());
|
||||
let deleting = $state<Set<string>>(new Set());
|
||||
|
||||
// Add task form
|
||||
let showAdd = $state(false);
|
||||
let newTitle = $state('');
|
||||
let newProjectId = $state('');
|
||||
let newDueDate = $state('');
|
||||
let newDueTime = $state('');
|
||||
let newAllDay = $state(true);
|
||||
let newPriority = $state(0);
|
||||
let newRepeat = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
// Edit
|
||||
let editingTask = $state<Task | null>(null);
|
||||
let editTitle = $state('');
|
||||
let editDueDate = $state('');
|
||||
let editDueTime = $state('');
|
||||
let editAllDay = $state(true);
|
||||
let editPriority = $state(0);
|
||||
let editRepeat = $state('');
|
||||
let editProjectId = $state('');
|
||||
|
||||
// New project
|
||||
let showNewProject = $state(false);
|
||||
let newProjectName = $state('');
|
||||
|
||||
async function api(path: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(`/api/tasks${path}`, { credentials: 'include', ...opts });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function formatDate(task: Task): string {
|
||||
const d = task.startDate || task.dueDate;
|
||||
if (!d) return 'No date';
|
||||
try {
|
||||
const date = new Date(d);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const taskDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const diff = Math.round((taskDate.getTime() - today.getTime()) / 86400000);
|
||||
|
||||
let dateStr = '';
|
||||
if (diff === 0) dateStr = 'Today';
|
||||
else if (diff === 1) dateStr = 'Tomorrow';
|
||||
else if (diff === -1) dateStr = 'Yesterday';
|
||||
else if (diff < -1) dateStr = `${Math.abs(diff)}d ago`;
|
||||
else dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
|
||||
if (!task.isAllDay) {
|
||||
const h = date.getHours();
|
||||
const m = date.getMinutes();
|
||||
if (h !== 0 || m !== 0) {
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
const hour = h % 12 || 12;
|
||||
const timeStr = m > 0 ? `${hour}:${String(m).padStart(2, '0')} ${ampm}` : `${hour} ${ampm}`;
|
||||
return `${dateStr} · ${timeStr}`;
|
||||
}
|
||||
}
|
||||
return dateStr;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function isOverdue(task: Task): boolean {
|
||||
const d = task.startDate || task.dueDate;
|
||||
if (!d) return false;
|
||||
const taskDate = new Date(d);
|
||||
const now = new Date();
|
||||
return taskDate < new Date(now.getFullYear(), now.getMonth(), now.getDate()) && task.status === 0;
|
||||
}
|
||||
|
||||
function priorityLabel(p: number): string {
|
||||
if (p >= 5) return 'high';
|
||||
if (p >= 3) return 'medium';
|
||||
if (p >= 1) return 'low';
|
||||
return '';
|
||||
}
|
||||
|
||||
function repeatLabel(flag: string): string {
|
||||
if (!flag) return '';
|
||||
if (flag.includes('DAILY')) return 'Daily';
|
||||
if (flag.includes('WEEKLY')) {
|
||||
const m = flag.match(/BYDAY=([A-Z,]+)/);
|
||||
if (m) return `Weekly (${m[1]})`;
|
||||
return 'Weekly';
|
||||
}
|
||||
if (flag.includes('MONTHLY')) return 'Monthly';
|
||||
if (flag.includes('YEARLY')) return 'Yearly';
|
||||
return 'Repeating';
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const data = await api('/projects');
|
||||
projects = data.projects || [];
|
||||
if (projects.length > 0 && !newProjectId) {
|
||||
newProjectId = projects[0].id;
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadToday() {
|
||||
try {
|
||||
const data = await api('/today');
|
||||
todayTasks = data.today || [];
|
||||
overdueTasks = data.overdue || [];
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const data = await api('/tasks');
|
||||
tasks = (data.tasks || []).filter((t: Task) => t.status === 0);
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadCompleted() {
|
||||
try {
|
||||
const data = await api('/tasks/completed');
|
||||
tasks = data.tasks || [];
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadProject(projectId: string) {
|
||||
try {
|
||||
const data = await api(`/tasks?project_id=${projectId}`);
|
||||
tasks = (data.tasks || []).filter((t: Task) => t.status === 0);
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
if (activeTab === 'today') await loadToday();
|
||||
else if (activeTab === 'all') await loadAll();
|
||||
else if (activeTab === 'completed') await loadCompleted();
|
||||
else if (activeTab === 'project' && selectedProjectId) await loadProject(selectedProjectId);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function addTask() {
|
||||
if (!newTitle.trim() || !newProjectId) return;
|
||||
saving = true;
|
||||
try {
|
||||
const payload: any = {
|
||||
title: newTitle.trim(),
|
||||
projectId: newProjectId,
|
||||
isAllDay: newAllDay,
|
||||
priority: newPriority,
|
||||
};
|
||||
if (newDueDate) {
|
||||
if (newAllDay) {
|
||||
payload.dueDate = `${newDueDate}T00:00:00+0000`;
|
||||
} else {
|
||||
payload.startDate = `${newDueDate}T${newDueTime || '09:00'}:00+0000`;
|
||||
payload.dueDate = `${newDueDate}T${newDueTime || '09:00'}:00+0000`;
|
||||
}
|
||||
}
|
||||
if (newRepeat) payload.repeatFlag = newRepeat;
|
||||
await api('/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
newTitle = '';
|
||||
newDueDate = '';
|
||||
newDueTime = '';
|
||||
newPriority = 0;
|
||||
newRepeat = '';
|
||||
showAdd = false;
|
||||
await loadData();
|
||||
} catch (e) { console.error('Failed to add task', e); }
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function completeTask(task: Task) {
|
||||
completing = new Set([...completing, task.id]);
|
||||
try {
|
||||
await api(`/tasks/${task.id}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: task.projectId || task._projectId })
|
||||
});
|
||||
setTimeout(loadData, 400);
|
||||
} catch {
|
||||
completing = new Set([...completing].filter(id => id !== task.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(task: Task) {
|
||||
if (!confirm(`Delete "${task.title}"?`)) return;
|
||||
deleting = new Set([...deleting, task.id]);
|
||||
try {
|
||||
await api(`/tasks/${task.id}?projectId=${task.projectId || task._projectId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setTimeout(loadData, 300);
|
||||
} catch {
|
||||
deleting = new Set([...deleting].filter(id => id !== task.id));
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(task: Task) {
|
||||
editingTask = task;
|
||||
editTitle = task.title;
|
||||
editAllDay = task.isAllDay ?? true;
|
||||
editPriority = task.priority || 0;
|
||||
editRepeat = task.repeatFlag || '';
|
||||
editProjectId = task.projectId || task._projectId;
|
||||
const d = task.startDate || task.dueDate || '';
|
||||
if (d) {
|
||||
try {
|
||||
const date = new Date(d);
|
||||
editDueDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
editDueTime = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
} catch { editDueDate = ''; editDueTime = ''; }
|
||||
} else {
|
||||
editDueDate = '';
|
||||
editDueTime = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingTask || !editTitle.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
const payload: any = {
|
||||
title: editTitle.trim(),
|
||||
projectId: editProjectId,
|
||||
isAllDay: editAllDay,
|
||||
priority: editPriority,
|
||||
repeatFlag: editRepeat || null,
|
||||
};
|
||||
if (editDueDate) {
|
||||
if (editAllDay) {
|
||||
payload.dueDate = `${editDueDate}T00:00:00+0000`;
|
||||
payload.startDate = `${editDueDate}T00:00:00+0000`;
|
||||
} else {
|
||||
payload.startDate = `${editDueDate}T${editDueTime || '09:00'}:00+0000`;
|
||||
payload.dueDate = `${editDueDate}T${editDueTime || '09:00'}:00+0000`;
|
||||
}
|
||||
} else {
|
||||
payload.dueDate = null;
|
||||
payload.startDate = null;
|
||||
}
|
||||
await api(`/tasks/${editingTask.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
editingTask = null;
|
||||
await loadData();
|
||||
} catch (e) { console.error('Failed to update task', e); }
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!newProjectName.trim()) return;
|
||||
try {
|
||||
await api('/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newProjectName.trim() })
|
||||
});
|
||||
newProjectName = '';
|
||||
showNewProject = false;
|
||||
await loadProjects();
|
||||
} catch (e) { console.error('Failed to create project', e); }
|
||||
}
|
||||
|
||||
function selectProject(projectId: string) {
|
||||
selectedProjectId = projectId;
|
||||
activeTab = 'project';
|
||||
loadData();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadProjects();
|
||||
await loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Tasks</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" onclick={() => { showNewProject = !showNewProject; }}>
|
||||
{showNewProject ? 'Cancel' : '+ List'}
|
||||
</button>
|
||||
<button class="btn-primary" onclick={() => { showAdd = !showAdd; }}>
|
||||
{showAdd ? 'Cancel' : '+ Add task'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showNewProject}
|
||||
<div class="module add-form">
|
||||
<div class="add-row">
|
||||
<input class="input" style="flex:1" placeholder="New list name" bind:value={newProjectName}
|
||||
onkeydown={(e) => e.key === 'Enter' && createProject()} />
|
||||
<button class="btn-primary" onclick={createProject} disabled={!newProjectName.trim()}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAdd}
|
||||
<div class="module add-form">
|
||||
<input class="input" placeholder="What needs to be done?" bind:value={newTitle}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTask()} />
|
||||
<div class="add-row">
|
||||
<select class="input add-select" bind:value={newProjectId}>
|
||||
{#each projects as proj}
|
||||
<option value={proj.id}>{proj.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input class="input add-date" type="date" bind:value={newDueDate} />
|
||||
{#if !newAllDay}
|
||||
<input class="input add-time" type="time" bind:value={newDueTime} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<label class="add-toggle">
|
||||
<input type="checkbox" bind:checked={newAllDay} /> All day
|
||||
</label>
|
||||
<select class="input add-priority" bind:value={newPriority}>
|
||||
<option value={0}>No priority</option>
|
||||
<option value={1}>Low</option>
|
||||
<option value={3}>Medium</option>
|
||||
<option value={5}>High</option>
|
||||
</select>
|
||||
<select class="input add-repeat" bind:value={newRepeat}>
|
||||
<option value="">No repeat</option>
|
||||
<option value="FREQ=DAILY">Daily</option>
|
||||
<option value="FREQ=WEEKLY">Weekly</option>
|
||||
<option value="FREQ=WEEKLY;BYDAY=MO,WE,FR">Mon/Wed/Fri</option>
|
||||
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
|
||||
<option value="FREQ=MONTHLY">Monthly</option>
|
||||
<option value="FREQ=YEARLY">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary full" onclick={addTask} disabled={saving || !newTitle.trim()}>
|
||||
{saving ? 'Adding...' : 'Add task'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="tab-bar tasks-tabs">
|
||||
<button class="tab" class:active={activeTab === 'today'} onclick={() => { activeTab = 'today'; loadData(); }}>Today</button>
|
||||
<button class="tab" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadData(); }}>All</button>
|
||||
{#each projects as proj}
|
||||
<button class="tab" class:active={activeTab === 'project' && selectedProjectId === proj.id} onclick={() => selectProject(proj.id)}>
|
||||
{proj.name}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="tab" class:active={activeTab === 'completed'} onclick={() => { activeTab = 'completed'; loadData(); }}>Done</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="module"><div class="skeleton" style="height: 200px"></div></div>
|
||||
{:else}
|
||||
|
||||
{#if activeTab === 'today'}
|
||||
{#if overdueTasks.length > 0}
|
||||
<div class="section-label overdue-label">Overdue · {overdueTasks.length}</div>
|
||||
<div class="module flush">
|
||||
{#each overdueTasks as task (task.id)}
|
||||
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
|
||||
<button class="task-check" onclick={() => completeTask(task)}>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--error)" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="task-body" onclick={() => startEdit(task)}>
|
||||
<div class="task-name">{task.title}</div>
|
||||
<div class="task-sub">
|
||||
{formatDate(task)} · {task._projectName}
|
||||
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if todayTasks.length > 0}
|
||||
<div class="section-label">Today · {todayTasks.length}</div>
|
||||
<div class="module flush">
|
||||
{#each todayTasks as task (task.id)}
|
||||
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
|
||||
<button class="task-check" onclick={() => completeTask(task)}>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--border-strong)" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="task-body" onclick={() => startEdit(task)}>
|
||||
<div class="task-name">{task.title}</div>
|
||||
<div class="task-sub">
|
||||
{formatDate(task)} · {task._projectName}
|
||||
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if todayTasks.length === 0 && overdueTasks.length === 0}
|
||||
<div class="module">
|
||||
<div class="empty-state">All clear for today</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if activeTab === 'completed'}
|
||||
{#if tasks.length > 0}
|
||||
<div class="module flush">
|
||||
{#each tasks as task (task.id)}
|
||||
<div class="data-row task-item completed-item">
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" style="flex-shrink:0">
|
||||
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--success)" stroke-width="2" fill="var(--success-dim)"/>
|
||||
<polyline points="7,11 10,14 15,8" stroke="var(--success)" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div class="task-body">
|
||||
<div class="task-name completed-name">{task.title}</div>
|
||||
<div class="task-sub">{task._projectName}{#if task.completedAt} · {new Date(task.completedAt).toLocaleDateString()}{/if}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="module"><div class="empty-state">No completed tasks</div></div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
{#if tasks.length > 0}
|
||||
<div class="module flush">
|
||||
{#each tasks as task (task.id)}
|
||||
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
|
||||
<button class="task-check" onclick={() => completeTask(task)}>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="{isOverdue(task) ? 'var(--error)' : 'var(--border-strong)'}" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="task-body" onclick={() => startEdit(task)}>
|
||||
<div class="task-name" class:overdue-text={isOverdue(task)}>{task.title}</div>
|
||||
<div class="task-sub">
|
||||
{formatDate(task)}
|
||||
{#if activeTab === 'all'} · {task._projectName}{/if}
|
||||
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
|
||||
{#if priorityLabel(task.priority)}
|
||||
<span class="badge {priorityLabel(task.priority) === 'high' ? 'error' : priorityLabel(task.priority) === 'medium' ? 'warning' : 'muted'}">{priorityLabel(task.priority)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="module">
|
||||
<div class="empty-state">No tasks</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edit modal -->
|
||||
{#if editingTask}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onclick={(e) => { if (e.target === e.currentTarget) editingTask = null; }} onkeydown={() => {}}>
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Edit task</h3>
|
||||
<button class="btn-icon" onclick={() => editingTask = null}>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input class="input" bind:value={editTitle} placeholder="Task title" />
|
||||
<select class="input" bind:value={editProjectId}>
|
||||
{#each projects as proj}
|
||||
<option value={proj.id}>{proj.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input class="input" type="date" bind:value={editDueDate} />
|
||||
<label class="add-toggle">
|
||||
<input type="checkbox" bind:checked={editAllDay} /> All day
|
||||
</label>
|
||||
{#if !editAllDay}
|
||||
<input class="input" type="time" bind:value={editDueTime} />
|
||||
{/if}
|
||||
<select class="input" bind:value={editPriority}>
|
||||
<option value={0}>No priority</option>
|
||||
<option value={1}>Low</option>
|
||||
<option value={3}>Medium</option>
|
||||
<option value={5}>High</option>
|
||||
</select>
|
||||
<select class="input" bind:value={editRepeat}>
|
||||
<option value="">No repeat</option>
|
||||
<option value="FREQ=DAILY">Daily</option>
|
||||
<option value="FREQ=WEEKLY">Weekly</option>
|
||||
<option value="FREQ=WEEKLY;BYDAY=MO,WE,FR">Mon/Wed/Fri</option>
|
||||
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
|
||||
<option value="FREQ=MONTHLY">Monthly</option>
|
||||
<option value="FREQ=YEARLY">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" onclick={() => editingTask = null}>Cancel</button>
|
||||
<button class="btn-primary" onclick={saveEdit} disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.tasks-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.tasks-tabs::-webkit-scrollbar { display: none; }
|
||||
.tasks-tabs > :global(.tab) {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding: var(--sp-1.5) var(--sp-3);
|
||||
font-size: var(--text-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.tasks-tabs > :global(.tab.active) {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--section-gap);
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-select { flex: 1; min-width: 120px; }
|
||||
.add-date { width: 160px; }
|
||||
.add-time { width: 120px; }
|
||||
.add-priority { width: 130px; }
|
||||
.add-repeat { width: 150px; }
|
||||
.add-toggle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.task-item.fading { opacity: 0.2; }
|
||||
|
||||
.task-check {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
margin-top: 2px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-check:hover svg rect {
|
||||
stroke: var(--accent);
|
||||
fill: var(--accent-dim);
|
||||
}
|
||||
|
||||
.task-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-1);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.task-name.overdue-text { color: var(--error); }
|
||||
.task-name.completed-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.completed-item { opacity: 0.7; }
|
||||
|
||||
.task-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
color: var(--text-4);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.task-item:hover .task-delete { opacity: 1; }
|
||||
|
||||
.overdue-label { color: var(--error) !important; }
|
||||
|
||||
.empty-state {
|
||||
padding: var(--sp-8);
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-xl);
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--card-pad) var(--card-pad) 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--sp-4) var(--card-pad);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--card-pad) var(--card-pad);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user