Refactor task components, add mobile-friendly enhancements, and improve settings management

- Introduced `TodoItemTouch`, a responsive task item optimized for mobile interaction with swipe actions.
- Added `MobileActions` for streamlined task creation and settings access on smaller screens.
- Refactored `App.vue` to adapt layouts dynamically for mobile and desktop users.
- Implemented collapsible, categorized task lists with improved handling in `TodoList` and `TodoListTouch`.
- Improved task swipe actions with `useSwipe` for smoother UI interactions.
- Updated styling with DaisyUI theme customization and multi-theme support.
- Enhanced `useSettings` with a `todayShown` toggle for quick agenda visibility.
This commit is contained in:
2026-03-04 10:41:23 +01:00
parent 2fea267ce9
commit 353bbea093
20 changed files with 500 additions and 241 deletions

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import type { Task } from '../types.ts'
import { PhPlus } from '@phosphor-icons/vue'
import { useTemplateRef } from 'vue'
import { useTasks } from '../composables/useTasks.ts'
import { router } from '../router.ts'
const { createTask } = useTasks()
async function handleSubmit(e: Event) {
const data = new FormData(e.target as HTMLFormElement)
const task: Partial<Task> = Object.fromEntries(data)
await createTask(task)
await router.push('/')
}
const createModal = useTemplateRef('createModal')
</script>
<template>
<div>
<div class="fab">
<button class="btn btn-xl btn-circle btn-secondary" @click="createModal?.showModal()">
<PhPlus size="24" weight="bold" />
</button>
</div>
<dialog ref="createModal" class="modal">
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
</button>
</form>
<form @submit.prevent="handleSubmit">
<fieldset class="fieldset">
<legend class="fieldset-legend">
What is your name?
</legend>
<input type="text" class="input" name="title" placeholder="Type here">
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">
Category
</legend>
<select class="select" name="tag">
<option disabled selected value="@uncategorized">
@uncategorized
</option>
<option value="@home">
@home
</option>
<option value="@work">
Amber
</option>
</select>
</fieldset>
<button class="btn btn-primary">
Submit
</button>
</form>
</div>
</dialog>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { PhDotsNine, PhPlus, PhSliders, PhX } from '@phosphor-icons/vue'
import { ref, useTemplateRef } from 'vue'
import CreateForm from './forms/CreateForm.vue'
import EditForm from './forms/EditForm.vue'
import SettingsForm from './forms/SettingsForm.vue'
const modalComponent = useTemplateRef('modalComponent')
type ModalShown = 'create' | 'edit' | 'settings'
const modalShown = ref<ModalShown>()
const componentMap = {
create: CreateForm,
edit: EditForm,
settings: SettingsForm,
}
function showModal(component: ModalShown) {
modalShown.value = component
modalComponent.value?.showModal()
}
</script>
<template>
<div>
<div class="fab select-none">
<!-- a focusable div with tabindex is necessary to work on all browsers. role="button" is necessary for accessibility -->
<div tabindex="0" role="button" class="btn btn-xl btn-circle bg-white border border-black border-2">
<PhDotsNine :size="30" weight="bold" />
</div>
<!-- close button should not be focusable so it can close the FAB when clicked. It's just a visual placeholder -->
<div class="fab-close">
<span class="btn btn-xl btn-circle bg-white border border-black border-2"><PhX size="30" weight="bold" /></span>
</div>
<!-- buttons that show up when FAB is open -->
<div>
<button class="btn btn-xl btn-circle bg-white border border-black border-2" @click="showModal('create')">
<PhPlus size="30" weight="bold" />
</button>
</div>
<div>
<button class="btn btn-xl btn-circle bg-white border border-black border-2" @click="showModal('settings')">
<PhSliders :size="30" weight="bold" />
</button>
</div>
</div>
<dialog ref="modalComponent" class="modal">
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
<PhX size="24" />
</button>
</form>
<component :is="componentMap[modalShown]" v-if="modalShown" />
</div>
</dialog>
</div>
</template>
<style scoped>
</style>

View File

@@ -3,9 +3,7 @@ import { PhX } from '@phosphor-icons/vue'
import { onKeyStroke, useMagicKeys, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import { useSettings } from '../composables/useSettings.ts'
const { settings } = useSettings()
import SettingsForm from './forms/SettingsForm.vue'
const settingsModal = useTemplateRef('settingsModal')
@@ -26,26 +24,16 @@ onKeyStroke('Escape', (e) => {
<template>
<div>
<dialog ref="settingsModal" class="modal">
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
<PhX size="24" />
</button>
</form>
<form @submit.prevent>
<fieldset class="fieldset">
<legend class="fieldset-legend">
Username
</legend>
<input v-model="settings.username" type="text" class="input" name="username">
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">
Password
</legend>
<input v-model="settings.password" type="text" class="input" name="password">
</fieldset>
</form>
<SettingsForm />
</div>
</dialog>
</div>

View File

@@ -1,14 +1,16 @@
<script setup lang="ts">
import type { UseSwipeDirection } from '@vueuse/core'
import type { Task } from '../types.ts'
import { PhCheckSquare, PhDotsThree, PhFlag, PhPause, PhPlay, PhSquare, PhX } from '@phosphor-icons/vue'
import { PhCheckSquare, PhClockCountdown, PhFlag, PhPause, PhPlay, PhSquare, PhTrash } from '@phosphor-icons/vue'
import { onClickOutside, useSwipe } from '@vueuse/core'
import { DateTime } from 'luxon'
import { computed, ref } from 'vue'
import { useTasks } from '../composables/useTasks.ts'
import { computed, shallowRef, useTemplateRef } from 'vue'
import useActions from '../composables/useActions.ts'
import { TaskStatus } from '../types.ts'
const { task } = defineProps<{ task: Task }>()
const { updateTask } = useTasks()
const { run } = useActions()
const dueColor = computed(() => {
const dueDiff = task.dueDate ? DateTime.fromMillis(task.dueDate).diffNow('days').days : undefined
@@ -28,57 +30,90 @@ const dueColor = computed(() => {
}
})
const statusSelectVisible = ref(false)
const target = useTemplateRef('target')
const container = useTemplateRef('container')
const containerWidth = computed(() => container.value?.offsetWidth)
const left = shallowRef('0')
const opacity = shallowRef(1)
async function handleClick(update: Partial<Task>) {
updateTask({ ...task, ...update })
statusSelectVisible.value = false
function reset() {
left.value = '0'
opacity.value = 1
}
const { isSwiping, lengthX } = useSwipe(
target,
{
passive: false,
onSwipe(_e: TouchEvent) {
if (containerWidth.value) {
if (lengthX.value < 0) {
const length = Math.abs(lengthX.value)
left.value = `${length}px`
opacity.value = 1.1 - length / containerWidth.value
}
else {
left.value = '0'
opacity.value = 1
}
}
},
onSwipeEnd(_e: TouchEvent, _direction: UseSwipeDirection) {
if (lengthX.value < 0 && containerWidth.value && (Math.abs(lengthX.value) / containerWidth.value) >= 0.5) {
left.value = '100%'
opacity.value = 0
}
else {
left.value = '0'
opacity.value = 1
}
},
},
)
async function handleClick(command: string) {
await run(command)
reset()
}
onClickOutside(container, reset)
</script>
<template>
<li class="list-row">
<div class="flex items-center justify-center">
<button class="btn btn-square btn-ghost" @click="statusSelectVisible = !statusSelectVisible">
<PhX v-if="statusSelectVisible" :size="20" />
<template v-else>
<PhSquare v-if="task.status === TaskStatus.WAIT" :size="20" />
<PhCheckSquare v-else-if="task.status === TaskStatus.DONE" :size="20" weight="fill" class="text-success" />
<PhFlag v-else-if="task.status === TaskStatus.FLAG" :size="20" weight="fill" class="text-warning" />
<PhPlay v-else-if="task.status === TaskStatus.WIP" :size="20" weight="fill" class="text-info" />
</template>
<li ref="container" class="badge badge-xl badge-neutral badge-outline w-full h-auto py-2 select-none relative overflow-hidden">
<div class=" flex flex-row justify-start gap-2 w-full">
<template v-if="task.status !== TaskStatus.WIP">
<button class="btn btn-square btn-ghost" @click="handleClick(`check ${task.id_}`)">
<PhCheckSquare v-if="task.status !== TaskStatus.DONE" :size="30" class="text-success" /><PhSquare v-else :size="30" />
</button>
<button v-if="task.status !== TaskStatus.FLAG" class="btn btn-square btn-ghost" @click="handleClick(`flag ${task.id_}`)">
<PhFlag :size="30" weight="fill" class="text-warning" />
</button>
<button class="btn btn-square btn-ghost" @click="handleClick(`start ${task.id_}`)">
<PhPlay :size="30" weight="fill" class="text-info" />
</button>
</template>
<button v-else class="btn btn-square btn-ghost" @click="handleClick(`stop ${task.id_}`)">
<PhPause :size="30" weight="fill" class="text-info" />
</button>
<Transition>
<div v-if="statusSelectVisible" class="">
<template v-if="task.status !== TaskStatus.WIP">
<button v-if="task.status !== TaskStatus.DONE" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.DONE })">
<PhCheckSquare :size="24" weight="regular" class="text-success" />
</button>
<button v-if="task.status !== TaskStatus.WAIT" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WAIT })">
<PhSquare :size="24" weight="regular" />
</button>
<button v-if="task.status !== TaskStatus.FLAG" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.FLAG })">
<PhFlag :size="24" weight="fill" class="text-warning" />
</button>
<button class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WIP })">
<PhPlay :size="24" weight="fill" class="text-info" />
</button>
</template>
<button v-else class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WAIT })">
<PhPause :size="24" weight="fill" class="text-info" />
</button>
</div>
</Transition>
</div>
<div class="flex flex-col justify-center">
<div>{{ task.id }} {{ task.id_ }} {{ task.title }}</div>
<div v-if="task.dueDate" :class="dueColor">
{{ DateTime.fromMillis(task.dueDate).toFormat('dd/MM/yyyy') }}
<div class="grow flex justify-end">
<button class="btn btn-square btn-ghost" @click="handleClick(`delete ${task.id_}`)">
<PhTrash :size="30" weight="fill" class="text-error" />
</button>
</div>
</div>
<div ref="target" :class="{ 'transition-all': !isSwiping }" :style="{ left, opacity }" class="top-0 left-0 w-full h-full absolute rounded bg-white font-mono text-md font-semibold flex flex-row justify-start items-center gap-2 min-h-10 px-2">
<div class="flex items-center justify-center">
<PhSquare v-if="task.status === TaskStatus.WAIT" :size="30" />
<PhCheckSquare v-else-if="task.status === TaskStatus.DONE" :size="30" weight="fill" class="text-success" />
<PhFlag v-else-if="task.status === TaskStatus.FLAG" :size="30" weight="fill" class="text-warning" />
<PhPlay v-else-if="task.status === TaskStatus.WIP" :size="30" weight="fill" class="text-info" />
</div>
<div class="grow">
{{ task.title }}
</div>
<div v-if="task.dueDate" :class="dueColor" class="text-right">
<PhClockCountdown :size="30" :weight="dueColor === 'text-error' ? 'fill' : 'regular'" />
</div>
</div>
<button class="btn btn-square btn-ghost">
<PhDotsThree :size="24" weight="regular" />
</button>
</li>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { Task } from '../types.ts'
import { ref } from 'vue'
import { useSettings } from '../composables/useSettings.ts'
import TodoItem from './TodoItem.vue'
const { categorizedTasks, today } = defineProps<{ categorizedTasks: Record<string, Task[]>, today: { today: Task[], next7days: Task[] } }>()
const { settings } = useSettings()
const collapsed = ref<string[]>([])
</script>
<template>
<div class="relative">
<div class="flex flex-col gap-4">
<div v-for="(catTasks, category) in categorizedTasks" :key="category" class="m-4 ">
<div class="mb-4">
<button class="badge badge-lg badge-neutral badge-outline font-mono font-bold" @click="collapsed.includes(category) ? collapsed.splice(collapsed.indexOf(category), 1) : collapsed.push(category)">
{{ category }} [{{ catTasks.length }}]
</button>
</div>
<Transition name="fade">
<ul v-if="!collapsed.includes(category)" class="space-y-2">
<TodoItem v-for="task in catTasks" :key="task.id" :task />
</ul>
</Transition>
</div>
</div>
<div :class="{ 'translate-x-full': !settings.todayShown }" class=" transition-transform duration-500 border-primary border-l-2 border-t-2 fixed bottom-0 right-0 top-0 w-1/2 max-w-sm text-center text-sm bg-neutral-50">
<div class="">
today
</div><div v-for="task in today.today" :key="task.id">
{{ task.title }}
</div>
<div class="">
next 7 days
</div><div v-for="task in today.next7days" :key="task.id">
{{ task.title }}
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { Task } from '../types.ts'
import { ref } from 'vue'
import { useSettings } from '../composables/useSettings.ts'
import TodoItemTouch from './TodoItemTouch.vue'
const { categorizedTasks, today } = defineProps<{ categorizedTasks: Record<string, Task[]>, today: { today: Task[], next7days: Task[] } }>()
const { settings } = useSettings()
const collapsed = ref<string[]>([])
</script>
<template>
<div class="relative">
<div class="flex flex-col gap-4">
<div v-for="(catTasks, category) in categorizedTasks" :key="category" class="m-4 ">
<div class="mb-4">
<button class="badge badge-xl badge-neutral badge-outline font-mono font-extrabold" @click="collapsed.includes(category) ? collapsed.splice(collapsed.indexOf(category), 1) : collapsed.push(category)">
{{ category }} [{{ catTasks.length }}]
</button>
</div>
<Transition name="fade">
<ul v-if="!collapsed.includes(category)" class="space-y-2">
<TodoItemTouch v-for="task in catTasks" :key="task.id" :task />
</ul>
</Transition>
</div>
</div>
<div :class="{ 'translate-x-full': !settings.todayShown }" class=" transition-transform duration-500 border-l border-t fixed bottom-0 right-0 top-0 w-1/2 max-w-sm text-center text-sm bg-neutral-50">
<div class="">
today
</div><div v-for="task in today.today" :key="task.id">
{{ task.title }}
</div>
<div class="">
next 7 days
</div><div v-for="task in today.next7days" :key="task.id">
{{ task.title }}
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { Task } from '../../types.ts'
import { useTasks } from '../../composables/useTasks.ts'
const { createTask } = useTasks()
async function handleSubmit(e: Event) {
const data = new FormData(e.target as HTMLFormElement)
const task = Object.fromEntries(data) as unknown as Pick<Task, 'tag' | 'title' | 'dueDate'>
await createTask(task)
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<fieldset class="fieldset">
<legend class="fieldset-legend">
What is your name?
</legend>
<input type="text" class="input" name="title" placeholder="Type here">
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">
Category
</legend>
<select class="select" name="tag">
<option disabled selected value="@uncategorized">
@uncategorized
</option>
<option value="@home">
@home
</option>
<option value="@work">
Amber
</option>
</select>
</fieldset>
<button class="btn btn-primary">
Submit
</button>
</form>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { Task } from '../../types.ts'
import { useTasks } from '../../composables/useTasks.ts'
const { createTask } = useTasks()
async function handleSubmit(e: Event) {
const data = new FormData(e.target as HTMLFormElement)
const task = Object.fromEntries(data) as unknown as Pick<Task, 'tag' | 'title' | 'dueDate'>
await createTask(task)
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<fieldset class="fieldset">
<legend class="fieldset-legend">
What is your name?
</legend>
<input type="text" class="input" name="title" placeholder="Type here">
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">
Category
</legend>
<select class="select" name="tag">
<option disabled selected value="@uncategorized">
@uncategorized
</option>
<option value="@home">
@home
</option>
<option value="@work">
Amber
</option>
</select>
</fieldset>
<button class="btn btn-primary">
Submit
</button>
</form>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useSettings } from '../../composables/useSettings.ts'
const { settings } = useSettings()
</script>
<template>
<form @submit.prevent>
<fieldset class="fieldset">
<legend class="fieldset-legend">
Username
</legend>
<input v-model="settings.username" type="text" class="input" name="username">
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">
Password
</legend>
<input v-model="settings.password" type="password" class="input" name="password">
</fieldset>
</form>
</template>