Files
remanso/src/components/TodoTxtItem.vue
Julien Calixte 8e9ca655a9
Some checks failed
CI / verify (push) Failing after 6s
2026-06-13 23:31:43
2026-06-13 23:31:43 +02:00

583 lines
16 KiB
Vue

<script setup lang="ts">
import { onClickOutside } from "@vueuse/core"
import { computed, nextTick, ref, useTemplateRef, watch } from "vue"
import {
removeTagFromBody,
removeTokenFromBody,
segmentBody,
Task,
toggleCompleted
} from "@/utils/todotxt"
const props = defineProps<{
task: Task
canEdit: boolean
}>()
const emit = defineEmits<{
update: [task: Task]
delete: []
}>()
const segments = computed(() => segmentBody(props.task.body))
const PRIORITIES = ["A", "B", "C", "D"] as const
const priorityBadgeClassFor = (priority?: string): string => {
const preClass = "badge badge-soft"
switch (priority) {
case "A":
return `${preClass} badge-error`
case "B":
return `${preClass} badge-warning`
case "C":
return `${preClass} badge-info`
default:
return `${preClass} badge-ghost`
}
}
const priorityBadgeClass = computed(() =>
priorityBadgeClassFor(props.task.priority)
)
const toggle = () => {
if (!props.canEdit) return
emit("update", toggleCompleted(props.task))
}
const priorityOpen = ref(false)
const priorityDropdown = useTemplateRef<HTMLDivElement>("priorityDropdown")
onClickOutside(priorityDropdown, () => {
priorityOpen.value = false
})
const togglePriorityMenu = () => {
if (!props.canEdit) return
priorityOpen.value = !priorityOpen.value
}
const setPriority = (priority: string | undefined) => {
if (!props.canEdit) return
emit("update", { ...props.task, priority })
priorityOpen.value = false
}
const removeToken = (kind: "project" | "context", value: string) => {
if (!props.canEdit) return
const newBody = removeTokenFromBody(
props.task.body,
kind === "project" ? "+" : "@",
value
)
emit("update", { ...props.task, body: newBody })
}
const removeTag = (key: "due" | "rec", value: string) => {
if (!props.canEdit) return
const newBody = removeTagFromBody(props.task.body, key, value)
emit("update", { ...props.task, body: newBody })
}
const startOfToday = (): Date => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
}
const parseDueDate = (value: string): Date | null => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
const [y, m, d] = value.split("-").map(Number)
return new Date(y, m - 1, d)
}
const dueClass = (value: string): string => {
const due = parseDueDate(value)
if (!due) return "badge badge-soft badge-accent"
const today = startOfToday().getTime()
if (due.getTime() < today) return "badge badge-soft badge-error"
if (due.getTime() === today) return "badge badge-soft badge-warning"
return "badge badge-soft badge-accent"
}
const MS_PER_DAY = 86_400_000
const formatDueDate = (value: string): string => {
const due = parseDueDate(value)
if (!due) return value
const diff = Math.round(
(due.getTime() - startOfToday().getTime()) / MS_PER_DAY
)
if (diff === 0) return "Today"
if (diff === 1) return "Tomorrow"
if (diff === -1) return "Yesterday"
return due.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric"
})
}
const dueTitle = (value: string): string => {
const due = parseDueDate(value)
if (!due) return `Due ${value}`
return `Due ${due.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
})}`
}
const REC_UNITS: Record<string, [string, string]> = {
d: ["day", "days"],
w: ["week", "weeks"],
m: ["month", "months"],
y: ["year", "years"]
}
const REC_ADVERBS: Record<string, string> = {
d: "Daily",
w: "Weekly",
m: "Monthly",
y: "Yearly"
}
const formatRecurrence = (value: string): string => {
const m = value.match(/^\+?(\d+)([dwmyDWMY])$/)
if (!m) return value
const n = parseInt(m[1], 10)
const unit = m[2].toLowerCase()
if (n === 1) return REC_ADVERBS[unit] ?? value
const [, plural] = REC_UNITS[unit]
return `Every ${n} ${plural}`
}
const recTitle = (value: string): string => {
const isStrict = value.startsWith("+")
const formatted = formatRecurrence(value)
return isStrict
? `${formatted} (from due date)`
: `${formatted} (from completion)`
}
const editing = ref(false)
const bodyDraft = ref("")
const inputRef = useTemplateRef<HTMLInputElement>("bodyInput")
const startEdit = () => {
if (!props.canEdit || props.task.completed) return
bodyDraft.value = props.task.body
editing.value = true
nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
}
const commitEdit = () => {
if (!editing.value) return
const next = bodyDraft.value.trim()
editing.value = false
if (next !== props.task.body) {
emit("update", { ...props.task, body: next })
}
}
const cancelEdit = () => {
editing.value = false
}
watch(
() => props.task.body,
() => {
if (editing.value) editing.value = false
}
)
</script>
<template>
<div
class="todo-row group flex items-start gap-2 py-1 px-2 rounded hover:bg-base-200"
:class="{ 'opacity-60': task.completed }"
>
<input
type="checkbox"
class="checkbox checkbox-success checkbox-sm mt-1"
:checked="task.completed"
:disabled="!canEdit"
@change="toggle"
/>
<div
ref="priorityDropdown"
class="todo-priority"
:class="{ 'todo-priority--open': priorityOpen }"
>
<button
type="button"
:class="priorityBadgeClass"
class="todo-priority-trigger cursor-pointer w-7 justify-center mt-0.5"
:disabled="!canEdit"
@click="togglePriorityMenu"
>
{{ task.priority ?? "—" }}
</button>
<div
v-if="canEdit && priorityOpen"
class="todo-priority-menu bg-base-200 rounded-box shadow p-2"
>
<button
v-for="p in PRIORITIES"
:key="p"
type="button"
:class="priorityBadgeClassFor(p)"
class="todo-priority-option w-10 justify-center cursor-pointer"
@click="setPriority(p)"
>
({{ p }})
</button>
<button
type="button"
:class="priorityBadgeClassFor(undefined)"
class="todo-priority-option w-10 justify-center cursor-pointer"
@click="setPriority(undefined)"
>
</button>
</div>
</div>
<div class="flex-1 min-w-0">
<input
v-if="editing"
ref="bodyInput"
v-model="bodyDraft"
type="text"
class="input input-ghost input-sm w-full"
@blur="commitEdit"
@keydown.enter.prevent="commitEdit"
@keydown.escape.prevent="cancelEdit"
/>
<div
v-else
class="body-display cursor-text"
:class="{ 'line-through': task.completed }"
@click="startEdit"
>
<template v-for="(seg, i) in segments" :key="i">
<span v-if="seg.kind === 'text'">{{ seg.value }}</span>
<span
v-else-if="seg.kind === 'project'"
class="todo-chip badge badge-soft badge-primary mx-0.5"
@click.stop
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-plus"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 5l0 14" />
<path d="M5 12l14 0" />
</svg>
{{ seg.value }}
<button
v-if="canEdit"
class="todo-chip-close"
type="button"
:aria-label="`Remove project ${seg.value}`"
@click.stop="removeToken('project', seg.value)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-x"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</span>
<span
v-else-if="seg.kind === 'context'"
class="todo-chip badge badge-soft badge-secondary mx-0.5"
@click.stop
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-at"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />
<path d="M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28" />
</svg>
{{ seg.value }}
<button
v-if="canEdit"
class="todo-chip-close"
type="button"
:aria-label="`Remove context ${seg.value}`"
@click.stop="removeToken('context', seg.value)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-x"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</span>
<span
v-else-if="seg.kind === 'due'"
:class="dueClass(seg.value) + ' mx-0.5 todo-chip'"
:title="dueTitle(seg.value)"
@click.stop
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-calendar"
width="16"
height="16"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"
/>
<path d="M16 3v4" />
<path d="M8 3v4" />
<path d="M4 11h16" />
</svg>
{{ formatDueDate(seg.value) }}
<button
v-if="canEdit"
class="todo-chip-close"
type="button"
:aria-label="`Remove due date ${seg.value}`"
@click.stop="removeTag('due', seg.value)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-x"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</span>
<span
v-else
class="todo-chip badge badge-soft badge-info mx-0.5"
:title="recTitle(seg.value)"
@click.stop
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-repeat"
width="16"
height="16"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" />
<path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />
</svg>
{{ formatRecurrence(seg.value) }}
<button
v-if="canEdit"
class="todo-chip-close"
type="button"
:aria-label="`Remove recurrence ${seg.value}`"
@click.stop="removeTag('rec', seg.value)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-x"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</span>
</template>
<span v-if="task.body.length === 0" class="opacity-50 italic">
(empty task)
</span>
</div>
</div>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition"
aria-label="Delete task"
@click="emit('delete')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-trash"
width="16"
height="16"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 7h16" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
</button>
</div>
</template>
<style scoped lang="scss">
.body-display {
word-break: break-word;
}
.todo-priority {
position: relative;
display: inline-flex;
}
// Lift the whole priority widget above sibling rows while its menu is open,
// otherwise the absolutely-positioned menu paints behind row N+1's content.
// 1 is enough: no other element in this view sets a non-auto z-index.
.todo-priority--open {
z-index: 1;
}
.todo-priority-trigger {
border: 0;
}
.todo-priority-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: auto;
}
.todo-priority-option {
border: 0;
font: inherit;
transition:
transform 120ms ease,
filter 120ms ease;
&:hover {
filter: brightness(1.1);
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.todo-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
vertical-align: baseline;
}
.todo-chip-close {
display: inline-flex;
align-items: center;
cursor: pointer;
margin-left: 0.1rem;
padding: 0;
background: transparent;
border: 0;
color: inherit;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
</style>