215 lines
5.9 KiB
Vue
215 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import type { Task } from '../types'
|
|
import * as api from '../services/api'
|
|
import PaginationControl from '../components/PaginationControl.vue'
|
|
import TodoEditorModal from '../components/TodoEditorModal.vue'
|
|
import { t } from '../i18n'
|
|
import { useToast } from '../composables/useToast'
|
|
|
|
const { success, error: showError } = useToast()
|
|
|
|
const tasks = ref<Task[]>([])
|
|
const loading = ref(false)
|
|
const modalOpen = ref(false)
|
|
const editingTask = ref<Task | null>(null)
|
|
const page = ref(1)
|
|
const pageSize = ref(6)
|
|
const filter = ref<'all' | 'todo' | 'done'>('all')
|
|
|
|
const filteredTasks = computed(() => {
|
|
if (filter.value === 'all') return tasks.value
|
|
return tasks.value.filter((t) => t.status === filter.value)
|
|
})
|
|
|
|
const total = computed(() => filteredTasks.value.length)
|
|
const pages = computed(() => Math.max(Math.ceil(total.value / pageSize.value), 1))
|
|
const pagedTasks = computed(() => {
|
|
const start = (page.value - 1) * pageSize.value
|
|
return filteredTasks.value.slice(start, start + pageSize.value)
|
|
})
|
|
|
|
async function loadTasks() {
|
|
loading.value = true
|
|
try {
|
|
const list = await api.listTasks()
|
|
tasks.value = list.sort((a, b) => Number(new Date(b.updated_at)) - Number(new Date(a.updated_at)))
|
|
if (page.value > pages.value) page.value = pages.value
|
|
} catch {
|
|
showError(t('load_tasks_failed'))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
editingTask.value = null
|
|
modalOpen.value = true
|
|
}
|
|
|
|
function openEdit(task: Task) {
|
|
editingTask.value = task
|
|
modalOpen.value = true
|
|
}
|
|
|
|
async function saveTask(payload: Partial<Task>) {
|
|
try {
|
|
if (editingTask.value?.id) {
|
|
await api.updateTask(editingTask.value.id, payload)
|
|
success(t('task_updated_success') || 'Task updated')
|
|
} else {
|
|
await api.createTask({ ...payload, status: 'todo' })
|
|
success(t('task_created_success') || 'Task created')
|
|
}
|
|
modalOpen.value = false
|
|
editingTask.value = null
|
|
await loadTasks()
|
|
} catch {
|
|
showError(t('save_task_failed'))
|
|
}
|
|
}
|
|
|
|
async function toggle(task: Task) {
|
|
const originalStatus = task.status
|
|
const newStatus = task.status === 'done' ? 'todo' : 'done'
|
|
|
|
// Optimistic update
|
|
task.status = newStatus
|
|
|
|
try {
|
|
await api.updateTask(task.id, { status: newStatus })
|
|
// Background refresh to ensure consistency
|
|
// await loadTasks() // Optional: might cause jumping if list sort changes
|
|
} catch {
|
|
// Revert on failure
|
|
task.status = originalStatus
|
|
showError(t('update_task_failed'))
|
|
}
|
|
}
|
|
|
|
async function remove(id: number) {
|
|
if (!confirm(t('confirm_delete') || 'Are you sure?')) return
|
|
|
|
const originalTasks = [...tasks.value]
|
|
tasks.value = tasks.value.filter(t => t.id !== id)
|
|
|
|
try {
|
|
await api.deleteTask(id)
|
|
success(t('task_deleted_success') || 'Task deleted')
|
|
} catch {
|
|
tasks.value = originalTasks
|
|
showError(t('delete_task_failed'))
|
|
}
|
|
}
|
|
|
|
function nextPage() {
|
|
if (page.value < pages.value) page.value += 1
|
|
}
|
|
|
|
function prevPage() {
|
|
if (page.value > 1) page.value -= 1
|
|
}
|
|
|
|
function formatDue(value: string) {
|
|
return value ? new Date(value).toLocaleString() : t('due_empty')
|
|
}
|
|
|
|
function setFilter(f: 'all' | 'todo' | 'done') {
|
|
filter.value = f
|
|
page.value = 1
|
|
}
|
|
|
|
onMounted(loadTasks)
|
|
</script>
|
|
|
|
<template>
|
|
<section class="page">
|
|
<header class="page-head">
|
|
<div>
|
|
<h2>{{ t('todo_title') }}</h2>
|
|
<p>{{ t('todo_subtitle') }}</p>
|
|
</div>
|
|
<div class="page-actions">
|
|
<div class="filter-group">
|
|
<button
|
|
class="btn sm"
|
|
:class="{ primary: filter === 'all' }"
|
|
@click="setFilter('all')"
|
|
>All</button>
|
|
<button
|
|
class="btn sm"
|
|
:class="{ primary: filter === 'todo' }"
|
|
@click="setFilter('todo')"
|
|
>Active</button>
|
|
<button
|
|
class="btn sm"
|
|
:class="{ primary: filter === 'done' }"
|
|
@click="setFilter('done')"
|
|
>Done</button>
|
|
</div>
|
|
<button class="btn" @click="loadTasks">{{ t('refresh') }}</button>
|
|
<button class="btn primary" @click="openCreate">{{ t('new_todo') }}</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="cards" v-if="!loading">
|
|
<div v-if="pagedTasks.length === 0" class="empty-state">
|
|
<p>No tasks found.</p>
|
|
</div>
|
|
<article v-for="task in pagedTasks" :key="task.id" class="task-row" :class="{ done: task.status === 'done' }">
|
|
<div class="task-row-main">
|
|
<input
|
|
class="task-check"
|
|
type="checkbox"
|
|
:checked="task.status === 'done'"
|
|
@change="toggle(task)"
|
|
:aria-label="task.title"
|
|
/>
|
|
<div class="task-row-content">
|
|
<h3>{{ task.title }}</h3>
|
|
<p>{{ task.description || t('no_description') }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="task-row-meta">
|
|
<div class="task-meta">
|
|
<small>{{ t('priority') }} P{{ task.priority || 2 }}</small>
|
|
<small>{{ t('due_at') }} {{ formatDue(task.due_at) }}</small>
|
|
</div>
|
|
</div>
|
|
<div class="task-row-actions">
|
|
<button class="btn" @click="openEdit(task)">{{ t('edit') }}</button>
|
|
<button class="btn danger" @click="remove(task.id)">{{ t('delete') }}</button>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<p v-else>{{ t('loading_tasks') }}</p>
|
|
|
|
<PaginationControl :page="page" :page-size="pageSize" :total="total" @next="nextPage" @prev="prevPage" />
|
|
|
|
<TodoEditorModal v-model="modalOpen" :editing-task="editingTask" @submit="saveTask" />
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.filter-group {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-right: 1rem;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 3rem;
|
|
color: #666;
|
|
background: rgba(255, 255, 255, 0.5);
|
|
border-radius: 8px;
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.btn.sm {
|
|
padding: 0.25rem 0.75rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
</style>
|