Add HelpPanel and TodoItemTouch components, extend task commands, and refactor task and view logic
- Introduced `HelpPanel.vue` for displaying keyboard shortcuts and command descriptions. - Added `TodoItemTouch.vue`, a mobile-friendly task item component with updated bindings and improved actions. - Extended task commands with support for tagging, due date parsing, and dynamic text formatting. - Implemented `useActions` utility for parsing and executing command-based task modifications. - Streamlined task editing and creation in `useTasks` for consistency and API integration. - Updated `ListScreen` to support collapsible, categorized task lists with visual enhancements. - Refactored `App.vue` for adaptive input handling on mobile versus desktop views. - Enhanced API communication in `useApi` with cleaner header generation and error handling.
This commit is contained in:
142
src/components/CreateInput.vue
Normal file
142
src/components/CreateInput.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside, onKeyStroke, onStartTyping } from '@vueuse/core'
|
||||
import { ref, useTemplateRef, watchEffect } from 'vue'
|
||||
import useActions from '../composables/useActions.ts'
|
||||
import useHistory from '../composables/useHistory.ts'
|
||||
import { useTasks } from '../composables/useTasks.ts'
|
||||
import HelpPanel from './HelpPanel.vue'
|
||||
|
||||
const inputComponent = useTemplateRef('inputComponent')
|
||||
const value = ref('')
|
||||
|
||||
const { run } = useActions()
|
||||
const { pushHistory, matchHistory, historyItem, moveHistory, resetHistoryIndex } = useHistory()
|
||||
const { tasks } = useTasks()
|
||||
|
||||
const inputActive = ref(false)
|
||||
const showHelp = ref(false)
|
||||
|
||||
const suggestion = ref('')
|
||||
const rememberTemp = ref('')
|
||||
|
||||
function resetInput() {
|
||||
value.value = ''
|
||||
suggestion.value = ''
|
||||
rememberTemp.value = value.value = ''
|
||||
resetHistoryIndex()
|
||||
inputActive.value = false
|
||||
inputComponent?.value?.blur()
|
||||
}
|
||||
|
||||
onKeyStroke('Escape', (e) => {
|
||||
e.preventDefault()
|
||||
resetInput()
|
||||
})
|
||||
|
||||
onClickOutside(inputComponent, resetInput)
|
||||
|
||||
onKeyStroke(' ', (e) => {
|
||||
if (!inputActive.value) {
|
||||
e.preventDefault()
|
||||
showHelp.value = true
|
||||
inputActive.value = true
|
||||
inputComponent?.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
onStartTyping(() => {
|
||||
showHelp.value = false
|
||||
inputActive.value = true
|
||||
inputComponent?.value?.focus()
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
run(value.value)
|
||||
pushHistory(value.value)
|
||||
resetInput()
|
||||
}
|
||||
|
||||
onKeyStroke('Tab', (e) => {
|
||||
if (inputActive.value && suggestion.value.length > 0) {
|
||||
e.preventDefault()
|
||||
value.value = suggestion.value
|
||||
}
|
||||
})
|
||||
|
||||
onKeyStroke('ArrowUp', (e) => {
|
||||
if (inputActive.value) {
|
||||
e.preventDefault()
|
||||
if (suggestion.value.trim().length > 0) {
|
||||
moveHistory('up')
|
||||
}
|
||||
else {
|
||||
rememberTemp.value = value.value
|
||||
}
|
||||
value.value = historyItem.value
|
||||
}
|
||||
else {
|
||||
inputActive.value = true
|
||||
inputComponent?.value?.focus()
|
||||
value.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
onKeyStroke('ArrowDown', (e) => {
|
||||
if (inputActive.value) {
|
||||
e.preventDefault()
|
||||
if (suggestion.value.trim().length > 0) {
|
||||
const before = historyItem.value
|
||||
moveHistory('down')
|
||||
const after = historyItem.value
|
||||
if (before !== after) {
|
||||
value.value = after
|
||||
}
|
||||
else {
|
||||
value.value = rememberTemp.value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
let match = ''
|
||||
if (value.value.length > 0) {
|
||||
const isEditCommand = value.value.match(/^e(?:dit)? (\d+)\s?$/i)
|
||||
if (isEditCommand && isEditCommand[1]) {
|
||||
const id = Number.parseInt(isEditCommand[1])
|
||||
if (id && !Number.isNaN(id)) {
|
||||
const found = (tasks.value.filter(t => t.id_ === id) || []).pop()
|
||||
if (found) {
|
||||
match = `${value.value.trim()} ${found.title}`
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
match = matchHistory(value.value.trim())
|
||||
}
|
||||
}
|
||||
suggestion.value = match
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex bottom-0 right-0 left-0 top-0 justify-center items-center flex-col gap-6 transition-opacity duration-500 bg-primary/10 " :class=" inputActive ? 'fixed' : 'opacity-0' " @submit.prevent="handleSubmit">
|
||||
<div class="font-mono w-10/12 max-w-md relative h-10">
|
||||
<div class="absolute top-0 left-0 right-0 bottom-0 bg-white px-4 py-2">
|
||||
{{ suggestion }}
|
||||
</div>
|
||||
<input
|
||||
ref="inputComponent"
|
||||
v-model="value" type="text"
|
||||
class="absolute top-0 left-0 right-0 bottom-0 bg-white/40 px-4 py-2 rounded-lg shadow-2xl focus:outline-none focus:ring-2 focus:ring-primary h-full w-full"
|
||||
>
|
||||
</div>
|
||||
<div v-if="showHelp" class="bg-white/70 backdrop-blur-xs p-6 rounded-lg shadow-xl text-center">
|
||||
<HelpPanel />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
68
src/components/CreateModal.vue
Normal file
68
src/components/CreateModal.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<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, tasks } = 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>
|
||||
92
src/components/HelpPanel.vue
Normal file
92
src/components/HelpPanel.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="font-mono text-sm text-left">
|
||||
<p>
|
||||
<b>
|
||||
<u>/</u>:
|
||||
</b>
|
||||
search for anything
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>t</u>ask:
|
||||
</b>
|
||||
create a new task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>e</u>dit:
|
||||
</b>
|
||||
edit a task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>due</u>:
|
||||
</b>
|
||||
set due date or <u>clear</u>
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>b</u>egin:
|
||||
</b>
|
||||
start timer
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>st</u>op:
|
||||
</b>
|
||||
stop timer
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>fl</u>ag:
|
||||
</b>
|
||||
flag a task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>move</u>:
|
||||
</b>
|
||||
move task to another tag
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>d</u>elete:
|
||||
</b>
|
||||
delete task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>a</u>rchive:
|
||||
</b>
|
||||
archive a task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>re</u>store:
|
||||
</b>
|
||||
unarchive a task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>sw</u>itch:
|
||||
</b>
|
||||
switch the working task
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>list-archived</u>:
|
||||
</b>
|
||||
show archived tasks
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
<u>today</u>:
|
||||
</b>
|
||||
show today overview
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '../types.ts'
|
||||
import { PhCheckSquare, PhDotsThree, PhFlag, PhPause, PhPlay, PhSquare, PhX } from '@phosphor-icons/vue'
|
||||
import { PhCheckSquare, PhFlag, PhPlay, PhSquare } from '@phosphor-icons/vue'
|
||||
import { DateTime } from 'luxon'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTasks } from '../composables/useTasks.ts'
|
||||
@@ -37,48 +37,20 @@ async function handleClick(update: Partial<Task>) {
|
||||
</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>
|
||||
</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.title }}</div>
|
||||
<div v-if="task.dueDate" :class="dueColor">
|
||||
{{ DateTime.fromMillis(task.dueDate).toFormat('dd/MM/yyyy') }}
|
||||
<li class="">
|
||||
<div class="font-mono text-sm flex flex-row justify-start gap-4">
|
||||
<span>{{ String(task.id_).padStart(3, ' ') }}</span>
|
||||
<div class="flex items-center justify-center">
|
||||
<PhSquare v-if="task.status === TaskStatus.WAIT" :size="16" />
|
||||
<PhCheckSquare v-else-if="task.status === TaskStatus.DONE" :size="16" weight="fill" class="text-success" />
|
||||
<PhFlag v-else-if="task.status === TaskStatus.FLAG" :size="16" weight="fill" class="text-warning" />
|
||||
<PhPlay v-else-if="task.status === TaskStatus.WIP" :size="16" weight="fill" class="text-info" />
|
||||
</div>
|
||||
<span>{{ task.title }}</span>
|
||||
<span v-if="task.dueDate" :class="dueColor">
|
||||
{{ DateTime.fromMillis(task.dueDate).toFormat('dd/MM/yyyy') }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-square btn-ghost">
|
||||
<PhDotsThree :size="24" weight="regular" />
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
|
||||
87
src/components/TodoItemTouch.vue
Normal file
87
src/components/TodoItemTouch.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '../types.ts'
|
||||
import { PhCheckSquare, PhDotsThree, PhFlag, PhPause, PhPlay, PhSquare, PhX } from '@phosphor-icons/vue'
|
||||
import { DateTime } from 'luxon'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTasks } from '../composables/useTasks.ts'
|
||||
import { TaskStatus } from '../types.ts'
|
||||
|
||||
const { task } = defineProps<{ task: Task }>()
|
||||
|
||||
const { updateTask } = useTasks()
|
||||
|
||||
const dueColor = computed(() => {
|
||||
const dueDiff = task.dueDate ? DateTime.fromMillis(task.dueDate).diffNow('days').days : undefined
|
||||
if (!dueDiff)
|
||||
return ''
|
||||
if (dueDiff < 0) {
|
||||
return 'text-error'
|
||||
}
|
||||
else if (dueDiff < 2) {
|
||||
return 'text-warning'
|
||||
}
|
||||
else if (dueDiff < 7) {
|
||||
return 'text-success'
|
||||
}
|
||||
else {
|
||||
return 'text-neutral'
|
||||
}
|
||||
})
|
||||
|
||||
const statusSelectVisible = ref(false)
|
||||
|
||||
async function handleClick(update: Partial<Task>) {
|
||||
updateTask({ ...task, ...update })
|
||||
statusSelectVisible.value = false
|
||||
}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<button class="btn btn-square btn-ghost">
|
||||
<PhDotsThree :size="24" weight="regular" />
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user