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:
14
src/App.vue
14
src/App.vue
@@ -3,8 +3,8 @@ import { useMediaQuery } from '@vueuse/core'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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 { useTasks } from './composables/useTasks.ts'
|
||||
|
||||
@@ -18,13 +18,17 @@ onMounted(fetchTasks)
|
||||
|
||||
<template>
|
||||
<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 />
|
||||
</main>
|
||||
<template v-if="currentPath === '/'">
|
||||
<CreateModal v-if="isMobile" />
|
||||
<CreateInput v-else />
|
||||
<template v-if="isMobile">
|
||||
<MobileActions />
|
||||
</template>
|
||||
<template v-else>
|
||||
<CreateInput />
|
||||
<SettingsModal />
|
||||
</template>
|
||||
</template>
|
||||
<SettingsModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
69
src/components/MobileActions.vue
Normal file
69
src/components/MobileActions.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
45
src/components/TodoList.vue
Normal file
45
src/components/TodoList.vue
Normal 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>
|
||||
45
src/components/TodoListTouch.vue
Normal file
45
src/components/TodoListTouch.vue
Normal 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>
|
||||
47
src/components/forms/CreateForm.vue
Normal file
47
src/components/forms/CreateForm.vue
Normal 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>
|
||||
47
src/components/forms/EditForm.vue
Normal file
47
src/components/forms/EditForm.vue
Normal 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>
|
||||
22
src/components/forms/SettingsForm.vue
Normal file
22
src/components/forms/SettingsForm.vue
Normal 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>
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
tagRenameCommand,
|
||||
} from '../utils/actions.ts'
|
||||
import { parseCommand } from '../utils/parser.ts'
|
||||
import { useSettings } from './useSettings.ts'
|
||||
import { useTasks } from './useTasks.ts'
|
||||
|
||||
export default function useActions() {
|
||||
const { tasks: tasksOriginal, createTask, updateTask } = useTasks()
|
||||
const run = (value: string) => {
|
||||
const { settings } = useSettings()
|
||||
const run = async (value: string) => {
|
||||
const cmd = parseCommand(value)
|
||||
let tasksToUpdate: Task[] = []
|
||||
let taskToCreate: Pick<Task, 'tag' | 'title' | 'dueDate'> | null = null
|
||||
@@ -81,10 +83,9 @@ export default function useActions() {
|
||||
case 'tagrename':
|
||||
tasksToUpdate = tagRenameCommand(cmd, tasks)
|
||||
break
|
||||
/* Visibility */
|
||||
// case 'hide':
|
||||
// updateCandidate = hideCommand(updateCandidate, cmd)
|
||||
// break
|
||||
case 'today':
|
||||
settings.value.todayShown = !settings.value.todayShown
|
||||
break
|
||||
// case 'show':
|
||||
// updateCandidate = showCommand(updateCandidate, cmd)
|
||||
// break
|
||||
@@ -97,12 +98,11 @@ export default function useActions() {
|
||||
// break
|
||||
}
|
||||
}
|
||||
// console.log(tasksToUpdate, taskToCreate)
|
||||
if (tasksToUpdate) {
|
||||
updateTask(tasksToUpdate)
|
||||
await updateTask(tasksToUpdate)
|
||||
}
|
||||
if (taskToCreate) {
|
||||
createTask(taskToCreate)
|
||||
await createTask(taskToCreate)
|
||||
}
|
||||
}
|
||||
return { run }
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useStore } from './useStore.ts'
|
||||
|
||||
const store = ref<string[]>([])
|
||||
const historyIndex = ref(0)
|
||||
|
||||
export default function useHistory() {
|
||||
const { getValue, setValue } = useStore()
|
||||
|
||||
const store = ref<string[]>([])
|
||||
const history = computed<string[]>(() => store.value)
|
||||
|
||||
const historyIndex = ref(0)
|
||||
|
||||
const resetHistoryIndex = () => {
|
||||
historyIndex.value = store.value.length - 1
|
||||
}
|
||||
|
||||
@@ -1,63 +1,67 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { useStore } from './useStore.ts';
|
||||
import { useCrypto } from './useCrypto.ts';
|
||||
import { ref, watch } from 'vue'
|
||||
import { useCrypto } from './useCrypto.ts'
|
||||
import { useStore } from './useStore.ts'
|
||||
|
||||
export type Settings = {
|
||||
username: string,
|
||||
password: string,
|
||||
export interface Settings {
|
||||
username: string
|
||||
password: string
|
||||
todayShown: boolean
|
||||
}
|
||||
|
||||
const settingsDefault: Settings = {
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
todayShown: false,
|
||||
}
|
||||
const settings = ref<Settings>({ ...settingsDefault })
|
||||
|
||||
export function useSettings() {
|
||||
const { setValue, getValue } = useStore();
|
||||
const { encrypt, decrypt } = useCrypto();
|
||||
const settings = ref<Settings>({ ...settingsDefault });
|
||||
const { setValue, getValue } = useStore()
|
||||
const { encrypt, decrypt } = useCrypto()
|
||||
|
||||
const loadSettings = async () => {
|
||||
const readSettings = await getValue<Settings>('settings');
|
||||
const readSettings = await getValue<Settings>('settings')
|
||||
if (!readSettings) {
|
||||
settings.value = { ...settingsDefault };
|
||||
return;
|
||||
settings.value = { ...settingsDefault }
|
||||
return
|
||||
}
|
||||
|
||||
let password = readSettings.password ?? '';
|
||||
let password = readSettings.password ?? ''
|
||||
if (password) {
|
||||
try {
|
||||
password = decrypt(password) as string;
|
||||
} catch (error) {
|
||||
console.warn('Failed to decrypt stored password:', error);
|
||||
password = decrypt(password) as string
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to decrypt stored password:', error)
|
||||
}
|
||||
}
|
||||
|
||||
settings.value = {
|
||||
username: readSettings.username ?? '',
|
||||
...settingsDefault,
|
||||
...readSettings,
|
||||
password,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
const encryptedPassword = settings.value.password
|
||||
? encrypt(settings.value.password) as string
|
||||
: '';
|
||||
: ''
|
||||
|
||||
await setValue<Settings>('settings', {
|
||||
username: settings.value.username,
|
||||
...settings.value,
|
||||
password: encryptedPassword,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
watch(settings, () => {
|
||||
void saveSettings();
|
||||
}, { deep: true });
|
||||
void saveSettings()
|
||||
}, { deep: true })
|
||||
|
||||
void loadSettings();
|
||||
void loadSettings()
|
||||
|
||||
return {
|
||||
settings,
|
||||
loadSettings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ const error = ref<string | null>(null)
|
||||
|
||||
export function useTasks() {
|
||||
const api = useApi()
|
||||
|
||||
const fetchTasks = async (force = false) => {
|
||||
if (tasks.value.length > 0 && !force)
|
||||
return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { router } from './router.ts';
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router.ts'
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ListScreen from './screens/ListScreen.vue'
|
||||
import SettingsScreen from './screens/SettingsScreen.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: ListScreen },
|
||||
{ path: '/settings', component: SettingsScreen },
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '../types.ts'
|
||||
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import TodoItem from '../components/TodoItem.vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
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'
|
||||
|
||||
const { tasks, fetchTasks } = useTasks()
|
||||
@@ -10,6 +13,7 @@ const { tasks, fetchTasks } = useTasks()
|
||||
onMounted(async () => {
|
||||
await fetchTasks()
|
||||
})
|
||||
const isMobile = useMediaQuery('(pointer: coarse)')
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<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>
|
||||
<component :is="isMobile ? TodoListTouch : TodoList" :today="today" :categorized-tasks="categorizedTasks" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -2,41 +2,80 @@
|
||||
@plugin "daisyui";
|
||||
|
||||
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "emerald";
|
||||
name: "lofi";
|
||||
default: true;
|
||||
prefersdark: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "light";
|
||||
--color-base-100: oklch(100% 0 0);
|
||||
--color-base-200: oklch(93% 0 0);
|
||||
--color-base-300: oklch(86% 0 0);
|
||||
--color-base-content: oklch(35.519% 0.032 262.988);
|
||||
--color-primary: oklch(76.662% 0.135 153.45);
|
||||
--color-primary-content: oklch(33.387% 0.04 162.24);
|
||||
--color-secondary: oklch(61.302% 0.202 261.294);
|
||||
--color-base-200: oklch(97% 0 0);
|
||||
--color-base-300: oklch(94% 0 0);
|
||||
--color-base-content: oklch(0% 0 0);
|
||||
--color-primary: oklch(15.906% 0 0);
|
||||
--color-primary-content: oklch(100% 0 0);
|
||||
--color-secondary: oklch(21.455% 0.001 17.278);
|
||||
--color-secondary-content: oklch(100% 0 0);
|
||||
--color-accent: oklch(72.772% 0.149 33.2);
|
||||
--color-accent-content: oklch(0% 0 0);
|
||||
--color-neutral: oklch(35.519% 0.032 262.988);
|
||||
--color-neutral-content: oklch(98.462% 0.001 247.838);
|
||||
--color-info: oklch(72.06% 0.191 231.6);
|
||||
--color-info-content: oklch(0% 0 0);
|
||||
--color-success: oklch(64.8% 0.15 160);
|
||||
--color-success-content: oklch(0% 0 0);
|
||||
--color-warning: oklch(84.71% 0.199 83.87);
|
||||
--color-warning-content: oklch(0% 0 0);
|
||||
--color-error: oklch(71.76% 0.221 22.18);
|
||||
--color-error-content: oklch(0% 0 0);
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--color-accent: oklch(26.861% 0 0);
|
||||
--color-accent-content: oklch(100% 0 0);
|
||||
--color-neutral: oklch(0% 0 0);
|
||||
--color-neutral-content: oklch(100% 0 0);
|
||||
--color-info: oklch(79.54% 0.103 205.9);
|
||||
--color-info-content: oklch(15.908% 0.02 205.9);
|
||||
--color-success: oklch(90.13% 0.153 164.14);
|
||||
--color-success-content: oklch(18.026% 0.03 164.14);
|
||||
--color-warning: oklch(88.37% 0.135 79.94);
|
||||
--color-warning-content: oklch(17.674% 0.027 79.94);
|
||||
--color-error: oklch(78.66% 0.15 28.47);
|
||||
--color-error-content: oklch(15.732% 0.03 28.47);
|
||||
--radius-selector: 0rem;
|
||||
--radius-field: 0rem;
|
||||
--radius-box: 0rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
--border: 2px;
|
||||
--depth: 1;
|
||||
--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 */
|
||||
|
||||
.fade-enter-active,
|
||||
|
||||
@@ -92,7 +92,6 @@ export function flagCommand(tasks: Task[], ids: Task['id_'][]) {
|
||||
t.status
|
||||
= t.status === TaskStatus.FLAG ? TaskStatus.WAIT : TaskStatus.FLAG
|
||||
t = stopWorkLogging(t)
|
||||
|
||||
return t
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user