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

@@ -3,8 +3,8 @@ import { useMediaQuery } from '@vueuse/core'
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CreateInput from './components/CreateInput.vue' import CreateInput from './components/CreateInput.vue'
import CreateModal from './components/CreateModal.vue'
import MobileActions from './components/MobileActions.vue'
import SettingsModal from './components/SettingsModal.vue' import SettingsModal from './components/SettingsModal.vue'
import { useTasks } from './composables/useTasks.ts' import { useTasks } from './composables/useTasks.ts'
@@ -18,13 +18,17 @@ onMounted(fetchTasks)
<template> <template>
<div> <div>
<main class="h-screen overflow-y-auto"> <main class="h-screen overflow-y-auto overflow-x-none w-screen max-w-screen border-t-2 border-primary">
<RouterView /> <RouterView />
</main> </main>
<template v-if="currentPath === '/'"> <template v-if="currentPath === '/'">
<CreateModal v-if="isMobile" /> <template v-if="isMobile">
<CreateInput v-else /> <MobileActions />
</template> </template>
<template v-else>
<CreateInput />
<SettingsModal /> <SettingsModal />
</template>
</template>
</div> </div>
</template> </template>

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 { onKeyStroke, useMagicKeys, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue' import { useTemplateRef } from 'vue'
import { useSettings } from '../composables/useSettings.ts' import SettingsForm from './forms/SettingsForm.vue'
const { settings } = useSettings()
const settingsModal = useTemplateRef('settingsModal') const settingsModal = useTemplateRef('settingsModal')
@@ -26,26 +24,16 @@ onKeyStroke('Escape', (e) => {
<template> <template>
<div> <div>
<dialog ref="settingsModal" class="modal"> <dialog ref="settingsModal" class="modal">
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
<div class="modal-box"> <div class="modal-box">
<form method="dialog"> <form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"> <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
<PhX size="24" /> <PhX size="24" />
</button> </button>
</form> </form>
<form @submit.prevent> <SettingsForm />
<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>
</div> </div>
</dialog> </dialog>
</div> </div>

View File

@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UseSwipeDirection } from '@vueuse/core'
import type { Task } from '../types.ts' 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 { DateTime } from 'luxon'
import { computed, ref } from 'vue' import { computed, shallowRef, useTemplateRef } from 'vue'
import { useTasks } from '../composables/useTasks.ts' import useActions from '../composables/useActions.ts'
import { TaskStatus } from '../types.ts' import { TaskStatus } from '../types.ts'
const { task } = defineProps<{ task: Task }>() const { task } = defineProps<{ task: Task }>()
const { updateTask } = useTasks() const { run } = useActions()
const dueColor = computed(() => { const dueColor = computed(() => {
const dueDiff = task.dueDate ? DateTime.fromMillis(task.dueDate).diffNow('days').days : undefined 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>) { function reset() {
updateTask({ ...task, ...update }) left.value = '0'
statusSelectVisible.value = false 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> </script>
<template> <template>
<li class="list-row"> <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 items-center justify-center"> <div class=" flex flex-row justify-start gap-2 w-full">
<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>
</button>
<Transition>
<div v-if="statusSelectVisible" class="">
<template v-if="task.status !== TaskStatus.WIP"> <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 })"> <button class="btn btn-square btn-ghost" @click="handleClick(`check ${task.id_}`)">
<PhCheckSquare :size="24" weight="regular" class="text-success" /> <PhCheckSquare v-if="task.status !== TaskStatus.DONE" :size="30" class="text-success" /><PhSquare v-else :size="30" />
</button> </button>
<button v-if="task.status !== TaskStatus.WAIT" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WAIT })"> <button v-if="task.status !== TaskStatus.FLAG" class="btn btn-square btn-ghost" @click="handleClick(`flag ${task.id_}`)">
<PhSquare :size="24" weight="regular" /> <PhFlag :size="30" weight="fill" class="text-warning" />
</button> </button>
<button v-if="task.status !== TaskStatus.FLAG" class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.FLAG })"> <button class="btn btn-square btn-ghost" @click="handleClick(`start ${task.id_}`)">
<PhFlag :size="24" weight="fill" class="text-warning" /> <PhPlay :size="30" weight="fill" class="text-info" />
</button>
<button class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WIP })">
<PhPlay :size="24" weight="fill" class="text-info" />
</button> </button>
</template> </template>
<button v-else class="btn btn-square btn-ghost" @click="handleClick({ status: TaskStatus.WAIT })"> <button v-else class="btn btn-square btn-ghost" @click="handleClick(`stop ${task.id_}`)">
<PhPause :size="24" weight="fill" class="text-info" /> <PhPause :size="30" weight="fill" class="text-info" />
</button>
<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> </button>
</div> </div>
</Transition>
</div> </div>
<div class="flex flex-col justify-center"> <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>{{ task.id }} {{ task.id_ }} {{ task.title }}</div> <div class="flex items-center justify-center">
<div v-if="task.dueDate" :class="dueColor"> <PhSquare v-if="task.status === TaskStatus.WAIT" :size="30" />
{{ DateTime.fromMillis(task.dueDate).toFormat('dd/MM/yyyy') }} <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>
</div> </div>
<button class="btn btn-square btn-ghost">
<PhDotsThree :size="24" weight="regular" />
</button>
</li> </li>
</template> </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>

View File

@@ -15,11 +15,13 @@ import {
tagRenameCommand, tagRenameCommand,
} from '../utils/actions.ts' } from '../utils/actions.ts'
import { parseCommand } from '../utils/parser.ts' import { parseCommand } from '../utils/parser.ts'
import { useSettings } from './useSettings.ts'
import { useTasks } from './useTasks.ts' import { useTasks } from './useTasks.ts'
export default function useActions() { export default function useActions() {
const { tasks: tasksOriginal, createTask, updateTask } = useTasks() const { tasks: tasksOriginal, createTask, updateTask } = useTasks()
const run = (value: string) => { const { settings } = useSettings()
const run = async (value: string) => {
const cmd = parseCommand(value) const cmd = parseCommand(value)
let tasksToUpdate: Task[] = [] let tasksToUpdate: Task[] = []
let taskToCreate: Pick<Task, 'tag' | 'title' | 'dueDate'> | null = null let taskToCreate: Pick<Task, 'tag' | 'title' | 'dueDate'> | null = null
@@ -81,10 +83,9 @@ export default function useActions() {
case 'tagrename': case 'tagrename':
tasksToUpdate = tagRenameCommand(cmd, tasks) tasksToUpdate = tagRenameCommand(cmd, tasks)
break break
/* Visibility */ case 'today':
// case 'hide': settings.value.todayShown = !settings.value.todayShown
// updateCandidate = hideCommand(updateCandidate, cmd) break
// break
// case 'show': // case 'show':
// updateCandidate = showCommand(updateCandidate, cmd) // updateCandidate = showCommand(updateCandidate, cmd)
// break // break
@@ -97,12 +98,11 @@ export default function useActions() {
// break // break
} }
} }
// console.log(tasksToUpdate, taskToCreate)
if (tasksToUpdate) { if (tasksToUpdate) {
updateTask(tasksToUpdate) await updateTask(tasksToUpdate)
} }
if (taskToCreate) { if (taskToCreate) {
createTask(taskToCreate) await createTask(taskToCreate)
} }
} }
return { run } return { run }

View File

@@ -1,14 +1,13 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useStore } from './useStore.ts' import { useStore } from './useStore.ts'
const store = ref<string[]>([])
const historyIndex = ref(0)
export default function useHistory() { export default function useHistory() {
const { getValue, setValue } = useStore() const { getValue, setValue } = useStore()
const store = ref<string[]>([])
const history = computed<string[]>(() => store.value) const history = computed<string[]>(() => store.value)
const historyIndex = ref(0)
const resetHistoryIndex = () => { const resetHistoryIndex = () => {
historyIndex.value = store.value.length - 1 historyIndex.value = store.value.length - 1
} }

View File

@@ -1,63 +1,67 @@
import { ref, watch } from 'vue'; import { ref, watch } from 'vue'
import { useStore } from './useStore.ts'; import { useCrypto } from './useCrypto.ts'
import { useCrypto } from './useCrypto.ts'; import { useStore } from './useStore.ts'
export type Settings = { export interface Settings {
username: string, username: string
password: string, password: string
todayShown: boolean
} }
const settingsDefault: Settings = { const settingsDefault: Settings = {
username: '', username: '',
password: '', password: '',
}; todayShown: false,
}
const settings = ref<Settings>({ ...settingsDefault })
export function useSettings() { export function useSettings() {
const { setValue, getValue } = useStore(); const { setValue, getValue } = useStore()
const { encrypt, decrypt } = useCrypto(); const { encrypt, decrypt } = useCrypto()
const settings = ref<Settings>({ ...settingsDefault });
const loadSettings = async () => { const loadSettings = async () => {
const readSettings = await getValue<Settings>('settings'); const readSettings = await getValue<Settings>('settings')
if (!readSettings) { if (!readSettings) {
settings.value = { ...settingsDefault }; settings.value = { ...settingsDefault }
return; return
} }
let password = readSettings.password ?? ''; let password = readSettings.password ?? ''
if (password) { if (password) {
try { try {
password = decrypt(password) as string; password = decrypt(password) as string
} catch (error) { }
console.warn('Failed to decrypt stored password:', error); catch (error) {
console.warn('Failed to decrypt stored password:', error)
} }
} }
settings.value = { settings.value = {
username: readSettings.username ?? '', ...settingsDefault,
...readSettings,
password, password,
}; }
}; }
const saveSettings = async () => { const saveSettings = async () => {
const encryptedPassword = settings.value.password const encryptedPassword = settings.value.password
? encrypt(settings.value.password) as string ? encrypt(settings.value.password) as string
: ''; : ''
await setValue<Settings>('settings', { await setValue<Settings>('settings', {
username: settings.value.username, ...settings.value,
password: encryptedPassword, password: encryptedPassword,
}); })
}; }
watch(settings, () => { watch(settings, () => {
void saveSettings(); void saveSettings()
}, { deep: true }); }, { deep: true })
void loadSettings(); void loadSettings()
return { return {
settings, settings,
loadSettings, loadSettings,
}; }
} }

View File

@@ -10,7 +10,6 @@ const error = ref<string | null>(null)
export function useTasks() { export function useTasks() {
const api = useApi() const api = useApi()
const fetchTasks = async (force = false) => { const fetchTasks = async (force = false) => {
if (tasks.value.length > 0 && !force) if (tasks.value.length > 0 && !force)
return return

View File

@@ -1,5 +1,5 @@
import { createApp } from "vue"; import { createApp } from 'vue'
import App from "./App.vue"; import App from './App.vue'
import { router } from './router.ts'; import { router } from './router.ts'
createApp(App).use(router).mount("#app"); createApp(App).use(router).mount('#app')

View File

@@ -1,10 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import ListScreen from './screens/ListScreen.vue' import ListScreen from './screens/ListScreen.vue'
import SettingsScreen from './screens/SettingsScreen.vue'
const routes = [ const routes = [
{ path: '/', component: ListScreen }, { path: '/', component: ListScreen },
{ path: '/settings', component: SettingsScreen },
] ]
export const router = createRouter({ export const router = createRouter({

View File

@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '../types.ts' import type { Task } from '../types.ts'
import { computed, onMounted, ref } from 'vue' import { useMediaQuery } from '@vueuse/core'
import TodoItem from '../components/TodoItem.vue' import { DateTime } from 'luxon'
import { computed, onMounted } from 'vue'
import TodoList from '../components/TodoList.vue'
import TodoListTouch from '../components/TodoListTouch.vue'
import { useTasks } from '../composables/useTasks.ts' import { useTasks } from '../composables/useTasks.ts'
const { tasks, fetchTasks } = useTasks() const { tasks, fetchTasks } = useTasks()
@@ -10,6 +13,7 @@ const { tasks, fetchTasks } = useTasks()
onMounted(async () => { onMounted(async () => {
await fetchTasks() await fetchTasks()
}) })
const isMobile = useMediaQuery('(pointer: coarse)')
const visibleTasks = computed<Task[]>(() => tasks.value.filter(task => !task.archived).sort((a, b) => a.id_ - b.id_)) const visibleTasks = computed<Task[]>(() => tasks.value.filter(task => !task.archived).sort((a, b) => a.id_ - b.id_))
@@ -29,26 +33,20 @@ const categorizedTasks = computed(() => {
) )
}) })
const collapsed = ref<string[]>([]) const today = computed(() => {
const now = DateTime.now().startOf('day')
const tasksDue = visibleTasks.value.filter(task => task.dueDate)
const today = tasksDue.filter(task => now.diff(DateTime.fromMillis(task.dueDate ?? 0), 'days').toObject().days === 0)
const next7days = tasksDue.filter((task) => {
const diff = now.diff(DateTime.fromMillis(task.dueDate ?? 0), 'days').toObject()
return diff.days && diff.days < 0 && diff.days >= -7
})
return { today, next7days }
})
</script> </script>
<template> <template>
<div> <component :is="isMobile ? TodoListTouch : TodoList" :today="today" :categorized-tasks="categorizedTasks" />
<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="px-4 py-1 rounded bg-neutral-200 font-mono text-sm 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="">
<TodoItem v-for="task in catTasks" :key="task.id" :task />
</ul>
</Transition>
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
</script>
<template>
<div />
</template>
<style scoped>
</style>

View File

@@ -2,41 +2,80 @@
@plugin "daisyui"; @plugin "daisyui";
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "emerald"; name: "lofi";
default: true; default: true;
prefersdark: true; prefersdark: false;
color-scheme: "light"; color-scheme: "light";
--color-base-100: oklch(100% 0 0); --color-base-100: oklch(100% 0 0);
--color-base-200: oklch(93% 0 0); --color-base-200: oklch(97% 0 0);
--color-base-300: oklch(86% 0 0); --color-base-300: oklch(94% 0 0);
--color-base-content: oklch(35.519% 0.032 262.988); --color-base-content: oklch(0% 0 0);
--color-primary: oklch(76.662% 0.135 153.45); --color-primary: oklch(15.906% 0 0);
--color-primary-content: oklch(33.387% 0.04 162.24); --color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(61.302% 0.202 261.294); --color-secondary: oklch(21.455% 0.001 17.278);
--color-secondary-content: oklch(100% 0 0); --color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(72.772% 0.149 33.2); --color-accent: oklch(26.861% 0 0);
--color-accent-content: oklch(0% 0 0); --color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(35.519% 0.032 262.988); --color-neutral: oklch(0% 0 0);
--color-neutral-content: oklch(98.462% 0.001 247.838); --color-neutral-content: oklch(100% 0 0);
--color-info: oklch(72.06% 0.191 231.6); --color-info: oklch(79.54% 0.103 205.9);
--color-info-content: oklch(0% 0 0); --color-info-content: oklch(15.908% 0.02 205.9);
--color-success: oklch(64.8% 0.15 160); --color-success: oklch(90.13% 0.153 164.14);
--color-success-content: oklch(0% 0 0); --color-success-content: oklch(18.026% 0.03 164.14);
--color-warning: oklch(84.71% 0.199 83.87); --color-warning: oklch(88.37% 0.135 79.94);
--color-warning-content: oklch(0% 0 0); --color-warning-content: oklch(17.674% 0.027 79.94);
--color-error: oklch(71.76% 0.221 22.18); --color-error: oklch(78.66% 0.15 28.47);
--color-error-content: oklch(0% 0 0); --color-error-content: oklch(15.732% 0.03 28.47);
--radius-selector: 1rem; --radius-selector: 0rem;
--radius-field: 0.5rem; --radius-field: 0rem;
--radius-box: 1rem; --radius-box: 0rem;
--size-selector: 0.25rem; --size-selector: 0.25rem;
--size-field: 0.25rem; --size-field: 0.25rem;
--border: 1px; --border: 2px;
--depth: 0; --depth: 1;
--noise: 0; --noise: 1;
} }
@plugin "daisyui/theme" {
name: "black";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(0% 0 0);
--color-base-200: oklch(19% 0 0);
--color-base-300: oklch(22% 0 0);
--color-base-content: oklch(87.609% 0 0);
--color-primary: oklch(35% 0 0);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(35% 0 0);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(35% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(35% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(45.201% 0.313 264.052);
--color-info-content: oklch(89.04% 0.062 264.052);
--color-success: oklch(51.975% 0.176 142.495);
--color-success-content: oklch(90.395% 0.035 142.495);
--color-warning: oklch(96.798% 0.211 109.769);
--color-warning-content: oklch(19.359% 0.042 109.769);
--color-error: oklch(62.795% 0.257 29.233);
--color-error-content: oklch(12.559% 0.051 29.233);
--radius-selector: 0rem;
--radius-field: 0rem;
--radius-box: 0rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 2px;
--depth: 1;
--noise: 1;
}
/* Transitions */ /* Transitions */
.fade-enter-active, .fade-enter-active,

View File

@@ -92,7 +92,6 @@ export function flagCommand(tasks: Task[], ids: Task['id_'][]) {
t.status t.status
= t.status === TaskStatus.FLAG ? TaskStatus.WAIT : TaskStatus.FLAG = t.status === TaskStatus.FLAG ? TaskStatus.WAIT : TaskStatus.FLAG
t = stopWorkLogging(t) t = stopWorkLogging(t)
return t return t
}) })
} }