Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso
All checks were successful
CI / verify (push) Successful in 2m5s
All checks were successful
CI / verify (push) Successful in 2m5s
This commit is contained in:
@@ -100,7 +100,7 @@ onUnmounted(() => {
|
|||||||
<h1 class="heading-1">
|
<h1 class="heading-1">
|
||||||
{{ repo }}
|
{{ repo }}
|
||||||
</h1>
|
</h1>
|
||||||
{{ user }}
|
· {{ user }}
|
||||||
</div>
|
</div>
|
||||||
<cache-all-notes />
|
<cache-all-notes />
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +148,10 @@ $header-height: 40px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
.heading-1 {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
&.content {
|
&.content {
|
||||||
.title,
|
.title,
|
||||||
h1,
|
h1,
|
||||||
|
|||||||
582
src/components/TodoTxtItem.vue
Normal file
582
src/components/TodoTxtItem.vue
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
<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>
|
||||||
87
src/hooks/useTodoTxtCommit.hook.ts
Normal file
87
src/hooks/useTodoTxtCommit.hook.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useDebounceFn } from "@vueuse/core"
|
||||||
|
import { onUnmounted, Ref, ref, toValue } from "vue"
|
||||||
|
|
||||||
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { FileLine, parseFile, serializeFile } from "@/utils/todotxt"
|
||||||
|
|
||||||
|
export const useTodoTxtCommit = ({
|
||||||
|
user,
|
||||||
|
repo,
|
||||||
|
path,
|
||||||
|
initialContent,
|
||||||
|
initialSha,
|
||||||
|
debounceMs = 1000
|
||||||
|
}: {
|
||||||
|
user: string
|
||||||
|
repo: string
|
||||||
|
path: Ref<string | undefined> | string | undefined
|
||||||
|
initialContent: Ref<string> | string
|
||||||
|
initialSha: Ref<string> | string
|
||||||
|
debounceMs?: number
|
||||||
|
}) => {
|
||||||
|
const { updateFile } = useGitHubContent({ user, repo })
|
||||||
|
|
||||||
|
const items = ref<FileLine[]>(parseFile(toValue(initialContent)))
|
||||||
|
const currentSha = ref(toValue(initialSha))
|
||||||
|
const isCommitting = ref(false)
|
||||||
|
const hasPendingChanges = ref(false)
|
||||||
|
|
||||||
|
// Re-seed from a freshly fetched file. We must not blow away in-flight edits.
|
||||||
|
const syncContent = (content: string, sha: string) => {
|
||||||
|
if (!hasPendingChanges.value) {
|
||||||
|
items.value = parseFile(content)
|
||||||
|
currentSha.value = sha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitChanges = async () => {
|
||||||
|
const pathValue = toValue(path)
|
||||||
|
if (!pathValue || !hasPendingChanges.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCommitting.value) {
|
||||||
|
debouncedCommit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isCommitting.value = true
|
||||||
|
|
||||||
|
const { sha: newSha } = await updateFile({
|
||||||
|
content: serializeFile(items.value),
|
||||||
|
path: pathValue,
|
||||||
|
sha: currentSha.value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (newSha) {
|
||||||
|
currentSha.value = newSha
|
||||||
|
hasPendingChanges.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
isCommitting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
|
||||||
|
|
||||||
|
const mutate = (mutator: (current: FileLine[]) => FileLine[]) => {
|
||||||
|
items.value = mutator(items.value)
|
||||||
|
hasPendingChanges.value = true
|
||||||
|
debouncedCommit()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Flush any pending edit on unmount so navigation doesn't lose work.
|
||||||
|
if (hasPendingChanges.value) {
|
||||||
|
commitChanges()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
currentSha,
|
||||||
|
isCommitting,
|
||||||
|
hasPendingChanges,
|
||||||
|
syncContent,
|
||||||
|
mutate
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -336,15 +336,6 @@ iframe {
|
|||||||
height: 400px;
|
height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO page */
|
|
||||||
.todo-notes input[type="checkbox"] {
|
|
||||||
@apply checkbox checkbox-success;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-notes li:has(> input[type="checkbox"]) {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs :where(pre):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
.tabs :where(pre):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|||||||
238
src/utils/todotxt.spec.ts
Normal file
238
src/utils/todotxt.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
contextsOf,
|
||||||
|
isBlank,
|
||||||
|
parseFile,
|
||||||
|
parseLine,
|
||||||
|
projectsOf,
|
||||||
|
removeTagFromBody,
|
||||||
|
removeTokenFromBody,
|
||||||
|
segmentBody,
|
||||||
|
serializeFile,
|
||||||
|
serializeTask,
|
||||||
|
tagsOf,
|
||||||
|
toggleCompleted
|
||||||
|
} from "./todotxt"
|
||||||
|
|
||||||
|
describe("todotxt parseLine", () => {
|
||||||
|
it("parses a plain task with no metadata", () => {
|
||||||
|
const task = parseLine("Pick up groceries")
|
||||||
|
expect(task.completed).toBe(false)
|
||||||
|
expect(task.priority).toBeUndefined()
|
||||||
|
expect(task.completionDate).toBeUndefined()
|
||||||
|
expect(task.creationDate).toBeUndefined()
|
||||||
|
expect(task.body).toBe("Pick up groceries")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses priority + creation date + body", () => {
|
||||||
|
const task = parseLine("(A) 2024-01-15 Call Mom +Family @phone")
|
||||||
|
expect(task.priority).toBe("A")
|
||||||
|
expect(task.creationDate).toBe("2024-01-15")
|
||||||
|
expect(task.body).toBe("Call Mom +Family @phone")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses a completed task with completion + creation dates", () => {
|
||||||
|
const task = parseLine("x 2024-01-20 (A) 2024-01-15 Finish report")
|
||||||
|
expect(task.completed).toBe(true)
|
||||||
|
expect(task.completionDate).toBe("2024-01-20")
|
||||||
|
expect(task.priority).toBe("A")
|
||||||
|
expect(task.creationDate).toBe("2024-01-15")
|
||||||
|
expect(task.body).toBe("Finish report")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses a completed task with no completion date", () => {
|
||||||
|
const task = parseLine("x Buy milk")
|
||||||
|
expect(task.completed).toBe(true)
|
||||||
|
expect(task.completionDate).toBeUndefined()
|
||||||
|
expect(task.body).toBe("Buy milk")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not treat uppercase X as completion", () => {
|
||||||
|
const task = parseLine("X-ray appointment")
|
||||||
|
expect(task.completed).toBe(false)
|
||||||
|
expect(task.body).toBe("X-ray appointment")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not match a lowercase priority", () => {
|
||||||
|
const task = parseLine("(a) lowercase priority is body text")
|
||||||
|
expect(task.priority).toBeUndefined()
|
||||||
|
expect(task.body).toBe("(a) lowercase priority is body text")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("todotxt serializeTask", () => {
|
||||||
|
it("round-trips a complex task", () => {
|
||||||
|
const input = "x 2024-01-20 (A) 2024-01-15 Finish report +work @office"
|
||||||
|
expect(serializeTask(parseLine(input))).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips a plain task", () => {
|
||||||
|
const input = "Buy milk"
|
||||||
|
expect(serializeTask(parseLine(input))).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trips priority + body", () => {
|
||||||
|
const input = "(B) Walk the dog"
|
||||||
|
expect(serializeTask(parseLine(input))).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("drops completion date when task is not completed", () => {
|
||||||
|
const task = parseLine("(A) 2024-01-15 Hello")
|
||||||
|
const out = serializeTask({ ...task, completionDate: "2024-02-01" })
|
||||||
|
expect(out).toBe("(A) 2024-01-15 Hello")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("todotxt projectsOf / contextsOf / tagsOf", () => {
|
||||||
|
it("extracts projects", () => {
|
||||||
|
const task = parseLine("Plan trip +Travel +Family @home")
|
||||||
|
expect(projectsOf(task)).toEqual(["Travel", "Family"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("extracts contexts", () => {
|
||||||
|
const task = parseLine("Call +Family @phone @home")
|
||||||
|
expect(contextsOf(task)).toEqual(["phone", "home"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("extracts key:value tags", () => {
|
||||||
|
const task = parseLine("Finish report due:2024-02-01 pri:A")
|
||||||
|
expect(tagsOf(task)).toEqual({ due: "2024-02-01", pri: "A" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not treat an email address as a context", () => {
|
||||||
|
const task = parseLine("Email boss at boss@example.com")
|
||||||
|
expect(contextsOf(task)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not treat a URL scheme as a tag", () => {
|
||||||
|
const task = parseLine("Read https://example.com later")
|
||||||
|
expect(tagsOf(task)).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("todotxt segmentBody", () => {
|
||||||
|
it("splits text + project + context", () => {
|
||||||
|
expect(segmentBody("Call Mom +Family @phone")).toEqual([
|
||||||
|
{ kind: "text", value: "Call Mom " },
|
||||||
|
{ kind: "project", value: "Family" },
|
||||||
|
{ kind: "text", value: " " },
|
||||||
|
{ kind: "context", value: "phone" }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps mid-word @ as text", () => {
|
||||||
|
expect(segmentBody("Email boss@example.com")).toEqual([
|
||||||
|
{ kind: "text", value: "Email boss@example.com" }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles a token at the start", () => {
|
||||||
|
expect(segmentBody("@phone Call Mom")).toEqual([
|
||||||
|
{ kind: "context", value: "phone" },
|
||||||
|
{ kind: "text", value: " Call Mom" }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("recognizes due: and rec: as their own segments", () => {
|
||||||
|
expect(segmentBody("Pay rent due:2025-01-15 rec:1m")).toEqual([
|
||||||
|
{ kind: "text", value: "Pay rent " },
|
||||||
|
{ kind: "due", value: "2025-01-15" },
|
||||||
|
{ kind: "text", value: " " },
|
||||||
|
{ kind: "rec", value: "1m" }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not match due: inside a word", () => {
|
||||||
|
expect(segmentBody("xdue:foo")).toEqual([
|
||||||
|
{ kind: "text", value: "xdue:foo" }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("todotxt removeTagFromBody", () => {
|
||||||
|
it("removes a due tag", () => {
|
||||||
|
expect(
|
||||||
|
removeTagFromBody("Pay rent due:2025-01-15 rec:1m", "due", "2025-01-15")
|
||||||
|
).toBe("Pay rent rec:1m")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes a rec tag at the end", () => {
|
||||||
|
expect(removeTagFromBody("Pay rent rec:1m", "rec", "1m")).toBe("Pay rent")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("todotxt removeTokenFromBody", () => {
|
||||||
|
it("removes a project token mid-body", () => {
|
||||||
|
expect(
|
||||||
|
removeTokenFromBody("Plan trip +Travel +Family @home", "+", "Travel")
|
||||||
|
).toBe("Plan trip +Family @home")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes a context token at the start", () => {
|
||||||
|
expect(removeTokenFromBody("@phone Call Mom", "@", "phone")).toBe(
|
||||||
|
"Call Mom"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes a token at the end", () => {
|
||||||
|
expect(removeTokenFromBody("Call Mom @phone", "@", "phone")).toBe(
|
||||||
|
"Call Mom"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("leaves untouched a token-prefix substring inside another word", () => {
|
||||||
|
expect(removeTokenFromBody("boss@example.com here", "@", "example")).toBe(
|
||||||
|
"boss@example.com here"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("todotxt parseFile / serializeFile", () => {
|
||||||
|
it("round-trips a file with trailing newline", () => {
|
||||||
|
const input = `(A) 2024-01-15 First task +work
|
||||||
|
x 2024-01-20 Second task
|
||||||
|
Plain task
|
||||||
|
`
|
||||||
|
const parsed = parseFile(input)
|
||||||
|
expect(parsed).toHaveLength(3)
|
||||||
|
expect(serializeFile(parsed)).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves blank lines", () => {
|
||||||
|
const input = `Task one
|
||||||
|
|
||||||
|
Task two
|
||||||
|
`
|
||||||
|
const parsed = parseFile(input)
|
||||||
|
expect(parsed).toHaveLength(3)
|
||||||
|
expect(isBlank(parsed[1])).toBe(true)
|
||||||
|
expect(serializeFile(parsed)).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles file with no trailing newline", () => {
|
||||||
|
const parsed = parseFile("only line")
|
||||||
|
expect(serializeFile(parsed, { trailingNewline: false })).toBe("only line")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("todotxt toggleCompleted", () => {
|
||||||
|
it("marks a task complete with today's date", () => {
|
||||||
|
// Construct via local-time fields so the test doesn't depend on the
|
||||||
|
// runner's timezone (todayIso uses getFullYear/getMonth/getDate).
|
||||||
|
const now = new Date(2024, 2, 15, 12, 0)
|
||||||
|
const task = parseLine("(A) 2024-01-15 Call Mom")
|
||||||
|
const toggled = toggleCompleted(task, now)
|
||||||
|
expect(toggled.completed).toBe(true)
|
||||||
|
expect(toggled.completionDate).toBe("2024-03-15")
|
||||||
|
expect(serializeTask(toggled)).toBe("x 2024-03-15 (A) 2024-01-15 Call Mom")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uncompletes a task and drops the completion date", () => {
|
||||||
|
const task = parseLine("x 2024-01-20 (A) 2024-01-15 Call Mom")
|
||||||
|
const toggled = toggleCompleted(task)
|
||||||
|
expect(toggled.completed).toBe(false)
|
||||||
|
expect(toggled.completionDate).toBeUndefined()
|
||||||
|
expect(serializeTask(toggled)).toBe("(A) 2024-01-15 Call Mom")
|
||||||
|
})
|
||||||
|
})
|
||||||
231
src/utils/todotxt.ts
Normal file
231
src/utils/todotxt.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
// Parser / serializer for the todo.txt format.
|
||||||
|
// Spec reference: https://github.com/todotxt/todo.txt
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
raw: string
|
||||||
|
completed: boolean
|
||||||
|
priority?: string
|
||||||
|
completionDate?: string
|
||||||
|
creationDate?: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlankLine {
|
||||||
|
blank: true
|
||||||
|
raw: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileLine = Task | BlankLine
|
||||||
|
|
||||||
|
export const isBlank = (line: FileLine): line is BlankLine =>
|
||||||
|
(line as BlankLine).blank === true
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
const PRIORITY_RE = /^\(([A-Z])\)$/
|
||||||
|
const PROJECT_TOKEN_RE = /(?:^|\s)\+(\S+)/g
|
||||||
|
const CONTEXT_TOKEN_RE = /(?:^|\s)@(\S+)/g
|
||||||
|
// Match key:value tags but skip URL-like values (anything containing a slash)
|
||||||
|
// to avoid grabbing the scheme of `https://example.com`.
|
||||||
|
const TAG_TOKEN_RE = /(?:^|\s)([A-Za-z][A-Za-z0-9_-]*):([^\s/]+)/g
|
||||||
|
|
||||||
|
const tryConsume = (
|
||||||
|
rest: string,
|
||||||
|
predicate: (token: string) => boolean
|
||||||
|
): { token: string; rest: string } | null => {
|
||||||
|
const match = rest.match(/^(\S+)(\s+|$)/)
|
||||||
|
if (!match || !predicate(match[1])) return null
|
||||||
|
return { token: match[1], rest: rest.slice(match[0].length) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseLine = (line: string): Task => {
|
||||||
|
let rest = line
|
||||||
|
let completed = false
|
||||||
|
let priority: string | undefined
|
||||||
|
let completionDate: string | undefined
|
||||||
|
let creationDate: string | undefined
|
||||||
|
|
||||||
|
if (rest.startsWith("x ")) {
|
||||||
|
completed = true
|
||||||
|
rest = rest.slice(2)
|
||||||
|
|
||||||
|
const dateAttempt = tryConsume(rest, (t) => DATE_RE.test(t))
|
||||||
|
if (dateAttempt) {
|
||||||
|
completionDate = dateAttempt.token
|
||||||
|
rest = dateAttempt.rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityAttempt = tryConsume(rest, (t) => PRIORITY_RE.test(t))
|
||||||
|
if (priorityAttempt) {
|
||||||
|
priority = priorityAttempt.token.slice(1, 2)
|
||||||
|
rest = priorityAttempt.rest
|
||||||
|
}
|
||||||
|
|
||||||
|
const creationAttempt = tryConsume(rest, (t) => DATE_RE.test(t))
|
||||||
|
if (creationAttempt) {
|
||||||
|
creationDate = creationAttempt.token
|
||||||
|
rest = creationAttempt.rest
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: line,
|
||||||
|
completed,
|
||||||
|
priority,
|
||||||
|
completionDate,
|
||||||
|
creationDate,
|
||||||
|
body: rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeTask = (task: Task): string => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (task.completed) {
|
||||||
|
parts.push("x")
|
||||||
|
if (task.completionDate) parts.push(task.completionDate)
|
||||||
|
}
|
||||||
|
if (task.priority) parts.push(`(${task.priority})`)
|
||||||
|
if (task.creationDate) parts.push(task.creationDate)
|
||||||
|
if (task.body.length) parts.push(task.body)
|
||||||
|
return parts.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseFile = (text: string): FileLine[] => {
|
||||||
|
const lines = text.split("\n")
|
||||||
|
// Drop the trailing empty element produced by a final newline.
|
||||||
|
// We re-add a trailing newline in serializeFile when there was one in the source.
|
||||||
|
const trailing = lines.length > 0 && lines[lines.length - 1] === ""
|
||||||
|
const meaningful = trailing ? lines.slice(0, -1) : lines
|
||||||
|
|
||||||
|
return meaningful.map((line) => {
|
||||||
|
if (line.trim() === "") return { blank: true, raw: line }
|
||||||
|
return parseLine(line)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeFile = (
|
||||||
|
items: FileLine[],
|
||||||
|
{ trailingNewline = true }: { trailingNewline?: boolean } = {}
|
||||||
|
): string => {
|
||||||
|
const lines = items.map((item) =>
|
||||||
|
isBlank(item) ? item.raw : serializeTask(item)
|
||||||
|
)
|
||||||
|
return lines.join("\n") + (trailingNewline ? "\n" : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectTokens = (body: string, re: RegExp): string[] => {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const match of body.matchAll(re)) {
|
||||||
|
out.push(match[1])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectsOf = (task: Task): string[] =>
|
||||||
|
collectTokens(task.body, PROJECT_TOKEN_RE)
|
||||||
|
|
||||||
|
export const contextsOf = (task: Task): string[] =>
|
||||||
|
collectTokens(task.body, CONTEXT_TOKEN_RE)
|
||||||
|
|
||||||
|
export const tagsOf = (task: Task): Record<string, string> => {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const match of task.body.matchAll(TAG_TOKEN_RE)) {
|
||||||
|
out[match[1]] = match[2]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BodySegment =
|
||||||
|
| { kind: "text"; value: string }
|
||||||
|
| { kind: "project" | "context"; value: string }
|
||||||
|
| { kind: "due" | "rec"; value: string }
|
||||||
|
|
||||||
|
// Split a task body into display segments. `due:VALUE` and `rec:VALUE` tags
|
||||||
|
// get their own segment kinds so the UI can render them as themed chips;
|
||||||
|
// `+project` and `@context` tokens become project/context segments.
|
||||||
|
// All tokens only count when they sit at the start of the body or after
|
||||||
|
// whitespace — so mid-word `@` (e.g. `boss@example.com`) stays as text.
|
||||||
|
export const segmentBody = (body: string): BodySegment[] => {
|
||||||
|
const segments: BodySegment[] = []
|
||||||
|
const re = /(due|rec):(\S+)|([+@])(\S+)/g
|
||||||
|
let last = 0
|
||||||
|
for (const match of body.matchAll(re)) {
|
||||||
|
const start = match.index ?? 0
|
||||||
|
const prevChar = start > 0 ? body[start - 1] : " "
|
||||||
|
if (!/\s/.test(prevChar)) continue
|
||||||
|
if (start > last) {
|
||||||
|
segments.push({ kind: "text", value: body.slice(last, start) })
|
||||||
|
}
|
||||||
|
if (match[1]) {
|
||||||
|
segments.push({
|
||||||
|
kind: match[1] as "due" | "rec",
|
||||||
|
value: match[2]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
segments.push({
|
||||||
|
kind: match[3] === "+" ? "project" : "context",
|
||||||
|
value: match[4]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
last = start + match[0].length
|
||||||
|
}
|
||||||
|
if (last < body.length) {
|
||||||
|
segments.push({ kind: "text", value: body.slice(last) })
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
|
||||||
|
// Remove a `+project` or `@context` token from a task body.
|
||||||
|
// Anchored on `(^|\s)` so mid-word matches (e.g. inside email addresses) are not touched.
|
||||||
|
export const removeTokenFromBody = (
|
||||||
|
body: string,
|
||||||
|
prefix: "+" | "@",
|
||||||
|
value: string
|
||||||
|
): string => {
|
||||||
|
const re = new RegExp(
|
||||||
|
`(?:^|\\s)\\${prefix}${escapeRegex(value)}(?=\\s|$)`,
|
||||||
|
"g"
|
||||||
|
)
|
||||||
|
return body.replace(re, "").replace(/[ \t]+/g, " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a `key:value` tag (e.g. `due:2025-01-15`) from a task body.
|
||||||
|
export const removeTagFromBody = (
|
||||||
|
body: string,
|
||||||
|
key: string,
|
||||||
|
value: string
|
||||||
|
): string => {
|
||||||
|
const re = new RegExp(
|
||||||
|
`(?:^|\\s)${escapeRegex(key)}:${escapeRegex(value)}(?=\\s|$)`,
|
||||||
|
"g"
|
||||||
|
)
|
||||||
|
return body.replace(re, "").replace(/[ \t]+/g, " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayIso = (now: Date): string => {
|
||||||
|
const y = now.getFullYear()
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, "0")
|
||||||
|
const d = String(now.getDate()).padStart(2, "0")
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the completed state of a task.
|
||||||
|
// On complete: stamp today's completion date. Priority stays in place per spec.
|
||||||
|
// On uncomplete: drop completion date but leave the rest of the line as-is —
|
||||||
|
// we deliberately do not try to recover any original priority that may have
|
||||||
|
// been stashed in a `pri:X` tag.
|
||||||
|
export const toggleCompleted = (task: Task, now = new Date()): Task => {
|
||||||
|
if (task.completed) {
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
completed: false,
|
||||||
|
completionDate: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
completed: true,
|
||||||
|
completionDate: todayIso(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +1,269 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, nextTick, ref, watch } from "vue"
|
import { computed, defineAsyncComponent, Ref, ref, watch } from "vue"
|
||||||
|
|
||||||
import { useCheckboxCommit } from "@/hooks/useCheckboxCommit.hook"
|
import TodoTxtItem from "@/components/TodoTxtItem.vue"
|
||||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { useTodoTxtCommit } from "@/hooks/useTodoTxtCommit.hook"
|
||||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
||||||
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
import {
|
||||||
|
contextsOf,
|
||||||
|
FileLine,
|
||||||
|
isBlank,
|
||||||
|
parseLine,
|
||||||
|
projectsOf,
|
||||||
|
Task
|
||||||
|
} from "@/utils/todotxt"
|
||||||
|
|
||||||
type Prop = {
|
type Prop = {
|
||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
|
const TODO_PATH = "todo.txt"
|
||||||
const props = defineProps<Prop>()
|
|
||||||
const user = computed(() => props.user)
|
|
||||||
const repo = computed(() => props.repo)
|
|
||||||
|
|
||||||
|
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
|
||||||
|
|
||||||
|
const props = defineProps<Prop>()
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
const todoNote = computed(() =>
|
const todoFile = computed(() => store.files.find((f) => f.path === TODO_PATH))
|
||||||
store.files.find((file) => file.path?.endsWith("_todo/todo.md"))
|
const sha = computed(() => todoFile.value?.sha ?? "")
|
||||||
)
|
|
||||||
|
|
||||||
const sha = computed(() => todoNote.value?.sha ?? "")
|
|
||||||
const path = computed(() => todoNote.value?.path)
|
|
||||||
|
|
||||||
const canPush = computed(() => store.canPush)
|
const canPush = computed(() => store.canPush)
|
||||||
|
|
||||||
const { toHTML } = markdownBuilder(repo)
|
const path = computed(() =>
|
||||||
|
todoFile.value?.path ? todoFile.value.path : TODO_PATH
|
||||||
|
)
|
||||||
|
|
||||||
const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } =
|
const { items, syncContent, mutate, hasPendingChanges } = useTodoTxtCommit({
|
||||||
useCheckboxCommit({
|
|
||||||
user: props.user,
|
user: props.user,
|
||||||
repo: props.repo,
|
repo: props.repo,
|
||||||
path,
|
path,
|
||||||
initialContent: "",
|
initialContent: "",
|
||||||
initialSha: sha,
|
initialSha: sha,
|
||||||
containerSelector: ".todo-notes .note-display",
|
debounceMs: 1000
|
||||||
debounceMs: 1000,
|
|
||||||
enabled: canPush
|
|
||||||
})
|
|
||||||
|
|
||||||
// Render pending content to HTML for display
|
|
||||||
const renderedContent = computed(() => {
|
|
||||||
if (!pendingContent.value) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return toHTML(pendingContent.value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch raw content when sha changes
|
|
||||||
watch(
|
watch(
|
||||||
sha,
|
sha,
|
||||||
async (newSha) => {
|
async (newSha) => {
|
||||||
if (!newSha || hasPendingChanges.value) {
|
if (!newSha || hasPendingChanges.value) return
|
||||||
return
|
const base64 = await queryFileContent(props.user, props.repo, newSha)
|
||||||
}
|
if (base64) {
|
||||||
const base64Content = await queryFileContent(props.user, props.repo, newSha)
|
syncContent(decodeBase64ToUTF8(base64), newSha)
|
||||||
if (base64Content) {
|
|
||||||
const rawContent = decodeBase64ToUTF8(base64Content)
|
|
||||||
syncContent(rawContent, newSha)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Setup checkbox listeners when content renders or canPush changes
|
// Rank `A` = 65 .. `Z` = 90; absent priority is Infinity so it sorts last
|
||||||
watch(
|
// regardless of locale collation quirks.
|
||||||
[renderedContent, canPush],
|
const priorityRank = (p?: string): number =>
|
||||||
async () => {
|
p ? p.charCodeAt(0) : Number.POSITIVE_INFINITY
|
||||||
await nextTick()
|
|
||||||
listenToCheckboxes()
|
const taskEntries = computed(() => {
|
||||||
},
|
const out: { line: Task; index: number }[] = []
|
||||||
{ immediate: true }
|
items.value.forEach((line, index) => {
|
||||||
|
if (!isBlank(line)) out.push({ line, index })
|
||||||
|
})
|
||||||
|
// Stable sort: equal priorities preserve file order.
|
||||||
|
out.sort(
|
||||||
|
(a, b) => priorityRank(a.line.priority) - priorityRank(b.line.priority)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const allProjects = computed(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
items.value.forEach((line) => {
|
||||||
|
if (!isBlank(line)) projectsOf(line).forEach((p) => set.add(p))
|
||||||
|
})
|
||||||
|
return Array.from(set).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const allContexts = computed(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
items.value.forEach((line) => {
|
||||||
|
if (!isBlank(line)) contextsOf(line).forEach((c) => set.add(c))
|
||||||
|
})
|
||||||
|
return Array.from(set).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
// "none" represents tasks without a priority.
|
||||||
|
const allPriorities = computed<Array<string | "none">>(() => {
|
||||||
|
const set = new Set<string | "none">()
|
||||||
|
items.value.forEach((line) => {
|
||||||
|
if (isBlank(line)) return
|
||||||
|
set.add(line.priority ?? "none")
|
||||||
|
})
|
||||||
|
return Array.from(set).sort((a, b) => {
|
||||||
|
if (a === "none") return 1
|
||||||
|
if (b === "none") return -1
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeProjects = ref<Set<string>>(new Set())
|
||||||
|
const activeContexts = ref<Set<string>>(new Set())
|
||||||
|
const activePriorities = ref<Set<string | "none">>(new Set())
|
||||||
|
|
||||||
|
// XOR: either "open" only, "done" only, or "all" (no status filter).
|
||||||
|
type StatusFilter = "all" | "open" | "done"
|
||||||
|
const statusFilter = ref<StatusFilter>("all")
|
||||||
|
const setStatusFilter = (next: Exclude<StatusFilter, "all">) => {
|
||||||
|
statusFilter.value = statusFilter.value === next ? "all" : next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop entries from `active` that no longer appear in `available`. Without
|
||||||
|
// this, editing tasks until a selected project/context/priority disappears
|
||||||
|
// leaves the filter "stuck on" — hasFilters stays true with no visible chip
|
||||||
|
// to toggle off.
|
||||||
|
const pruneActive = <T>(active: Ref<Set<T>>, available: ReadonlyArray<T>) => {
|
||||||
|
const valid = new Set(available)
|
||||||
|
const pruned = new Set<T>()
|
||||||
|
active.value.forEach((v) => {
|
||||||
|
if (valid.has(v)) pruned.add(v)
|
||||||
|
})
|
||||||
|
if (pruned.size !== active.value.size) active.value = pruned
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(allProjects, (next) => pruneActive(activeProjects, next))
|
||||||
|
watch(allContexts, (next) => pruneActive(activeContexts, next))
|
||||||
|
watch(allPriorities, (next) => pruneActive(activePriorities, next))
|
||||||
|
|
||||||
|
const toggleProject = (p: string) => {
|
||||||
|
const next = new Set(activeProjects.value)
|
||||||
|
if (next.has(p)) next.delete(p)
|
||||||
|
else next.add(p)
|
||||||
|
activeProjects.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleContext = (c: string) => {
|
||||||
|
const next = new Set(activeContexts.value)
|
||||||
|
if (next.has(c)) next.delete(c)
|
||||||
|
else next.add(c)
|
||||||
|
activeContexts.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePriority = (p: string | "none") => {
|
||||||
|
const next = new Set(activePriorities.value)
|
||||||
|
if (next.has(p)) next.delete(p)
|
||||||
|
else next.add(p)
|
||||||
|
activePriorities.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
activeProjects.value = new Set()
|
||||||
|
activeContexts.value = new Set()
|
||||||
|
activePriorities.value = new Set()
|
||||||
|
statusFilter.value = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = computed(
|
||||||
|
() =>
|
||||||
|
activeProjects.value.size > 0 ||
|
||||||
|
activeContexts.value.size > 0 ||
|
||||||
|
activePriorities.value.size > 0 ||
|
||||||
|
statusFilter.value !== "all"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const priorityBadgeFilterClass = (p: string | "none"): string => {
|
||||||
|
switch (p) {
|
||||||
|
case "A":
|
||||||
|
return "badge-error"
|
||||||
|
case "B":
|
||||||
|
return "badge-warning"
|
||||||
|
case "C":
|
||||||
|
return "badge-info"
|
||||||
|
default:
|
||||||
|
return "badge-ghost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredEntries = computed(() => {
|
||||||
|
if (!hasFilters.value) return taskEntries.value
|
||||||
|
return taskEntries.value.filter(({ line }) => {
|
||||||
|
const taskProjects = projectsOf(line)
|
||||||
|
const taskContexts = contextsOf(line)
|
||||||
|
const projectsOk =
|
||||||
|
activeProjects.value.size === 0 ||
|
||||||
|
taskProjects.some((p) => activeProjects.value.has(p))
|
||||||
|
const contextsOk =
|
||||||
|
activeContexts.value.size === 0 ||
|
||||||
|
taskContexts.some((c) => activeContexts.value.has(c))
|
||||||
|
const prioritiesOk =
|
||||||
|
activePriorities.value.size === 0 ||
|
||||||
|
activePriorities.value.has(line.priority ?? "none")
|
||||||
|
const statusOk =
|
||||||
|
statusFilter.value === "all" ||
|
||||||
|
(statusFilter.value === "done" ? line.completed : !line.completed)
|
||||||
|
return projectsOk && contextsOk && prioritiesOk && statusOk
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateTask = (index: number, next: Task) => {
|
||||||
|
mutate((current) => current.map((line, i) => (i === index ? next : line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTask = (index: number) => {
|
||||||
|
mutate((current) => current.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTaskInput = ref("")
|
||||||
|
|
||||||
|
const addTask = () => {
|
||||||
|
const text = newTaskInput.value.trim()
|
||||||
|
if (!text) return
|
||||||
|
const task = parseLine(text)
|
||||||
|
newTaskInput.value = ""
|
||||||
|
mutate((current) => {
|
||||||
|
const next: FileLine[] = [...current, task]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createFile } = useGitHubContent({ user: props.user, repo: props.repo })
|
||||||
|
const isCreating = ref(false)
|
||||||
|
|
||||||
|
// Pin a leaving row's box to its current coordinates before Vue makes it
|
||||||
|
// position: absolute. Without this, browsers (notably Firefox) compute the
|
||||||
|
// static position of an absolutely-positioned former-flex child to (0,0),
|
||||||
|
// so multiple leaving rows stack on top of each other and the remaining
|
||||||
|
// rows snap into place once the DOM nodes are removed.
|
||||||
|
const lockLeavingPosition = (el: Element) => {
|
||||||
|
const li = el as HTMLElement
|
||||||
|
const parent = li.parentElement
|
||||||
|
if (!parent) return
|
||||||
|
const liRect = li.getBoundingClientRect()
|
||||||
|
const parentRect = parent.getBoundingClientRect()
|
||||||
|
li.style.top = `${liRect.top - parentRect.top}px`
|
||||||
|
li.style.left = `${liRect.left - parentRect.left}px`
|
||||||
|
li.style.width = `${liRect.width}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTodoFile = async () => {
|
||||||
|
if (isCreating.value) return
|
||||||
|
isCreating.value = true
|
||||||
|
const { sha: newSha, conflict } = await createFile({
|
||||||
|
content: "",
|
||||||
|
path: TODO_PATH
|
||||||
|
})
|
||||||
|
isCreating.value = false
|
||||||
|
|
||||||
|
if (conflict) return
|
||||||
|
if (!newSha) {
|
||||||
|
errorMessage("Could not create todo.txt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store.registerUploadedFile({
|
||||||
|
path: TODO_PATH,
|
||||||
|
sha: newSha,
|
||||||
|
type: "blob"
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -83,22 +272,264 @@ watch(
|
|||||||
key="todo-notes"
|
key="todo-notes"
|
||||||
:user="user"
|
:user="user"
|
||||||
:repo="repo"
|
:repo="repo"
|
||||||
:content="renderedContent"
|
content=""
|
||||||
:parse-content="false"
|
:parse-content="false"
|
||||||
|
:with-content="false"
|
||||||
|
>
|
||||||
|
<h3 class="subtitle">Todo</h3>
|
||||||
|
|
||||||
|
<div v-if="!todoFile" class="card bg-base-200">
|
||||||
|
<div class="card-body items-center text-center">
|
||||||
|
<p>No <code>todo.txt</code> in this repo yet.</p>
|
||||||
|
<div v-if="canPush" class="card-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="isCreating"
|
||||||
|
@click="createTodoFile"
|
||||||
|
>
|
||||||
|
Create todo.txt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm opacity-70">
|
||||||
|
Sign in to a repo you can push to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<form v-if="canPush" class="mb-4 join w-full" @submit.prevent="addTask">
|
||||||
|
<input
|
||||||
|
v-model="newTaskInput"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm join-item flex-1"
|
||||||
|
placeholder="(A) task +project @context…"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-sm btn-primary join-item"
|
||||||
|
:disabled="!newTaskInput.trim()"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
allProjects.length || allContexts.length || allPriorities.length
|
||||||
|
"
|
||||||
|
class="todo-filters"
|
||||||
|
>
|
||||||
|
<div class="todo-filter-columns">
|
||||||
|
<section class="todo-filter-column">
|
||||||
|
<h4 class="todo-filter-label">Status</h4>
|
||||||
|
<div class="todo-filter-chips">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="todo-filter-chip badge badge-success"
|
||||||
|
:class="{ 'badge-soft': statusFilter !== 'open' }"
|
||||||
|
@click="setStatusFilter('open')"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="todo-filter-chip badge badge-neutral"
|
||||||
|
:class="{ 'badge-soft': statusFilter !== 'done' }"
|
||||||
|
@click="setStatusFilter('done')"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section v-if="allPriorities.length" class="todo-filter-column">
|
||||||
|
<h4 class="todo-filter-label">Priorities</h4>
|
||||||
|
<div class="todo-filter-chips">
|
||||||
|
<button
|
||||||
|
v-for="p in allPriorities"
|
||||||
|
:key="`pri:${p}`"
|
||||||
|
type="button"
|
||||||
|
class="todo-filter-chip badge"
|
||||||
|
:class="[
|
||||||
|
priorityBadgeFilterClass(p),
|
||||||
|
{ 'badge-soft': !activePriorities.has(p) }
|
||||||
|
]"
|
||||||
|
@click="togglePriority(p)"
|
||||||
|
>
|
||||||
|
{{ p === "none" ? "—" : `(${p})` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section v-if="allProjects.length" class="todo-filter-column">
|
||||||
|
<h4 class="todo-filter-label">Projects</h4>
|
||||||
|
<div class="todo-filter-chips">
|
||||||
|
<button
|
||||||
|
v-for="p in allProjects"
|
||||||
|
:key="`p:${p}`"
|
||||||
|
type="button"
|
||||||
|
class="todo-filter-chip badge badge-primary"
|
||||||
|
:class="{ 'badge-soft': !activeProjects.has(p) }"
|
||||||
|
@click="toggleProject(p)"
|
||||||
|
>
|
||||||
|
+{{ p }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section v-if="allContexts.length" class="todo-filter-column">
|
||||||
|
<h4 class="todo-filter-label">Contexts</h4>
|
||||||
|
<div class="todo-filter-chips">
|
||||||
|
<button
|
||||||
|
v-for="c in allContexts"
|
||||||
|
:key="`c:${c}`"
|
||||||
|
type="button"
|
||||||
|
class="todo-filter-chip badge badge-secondary"
|
||||||
|
:class="{ 'badge-soft': !activeContexts.has(c) }"
|
||||||
|
@click="toggleContext(c)"
|
||||||
|
>
|
||||||
|
@{{ c }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm self-start"
|
||||||
|
:class="{ invisible: !hasFilters }"
|
||||||
|
:aria-hidden="!hasFilters"
|
||||||
|
:tabindex="hasFilters ? 0 : -1"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransitionGroup
|
||||||
|
name="todo"
|
||||||
|
tag="ul"
|
||||||
|
class="todo-list"
|
||||||
|
@before-leave="lockLeavingPosition"
|
||||||
|
>
|
||||||
|
<li v-for="entry in filteredEntries" :key="entry.index">
|
||||||
|
<todo-txt-item
|
||||||
|
:task="entry.line"
|
||||||
|
:can-edit="canPush"
|
||||||
|
@update="(t) => updateTask(entry.index, t)"
|
||||||
|
@delete="deleteTask(entry.index)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="filteredEntries.length === 0 && hasFilters"
|
||||||
|
class="text-sm opacity-60 mt-3"
|
||||||
|
>
|
||||||
|
No tasks match the current filters.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="taskEntries.length === 0 && canPush"
|
||||||
|
class="text-sm opacity-60 mt-3"
|
||||||
|
>
|
||||||
|
Your todo list is empty. Add a task above.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</flux-note>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style scoped lang="scss">
|
||||||
.todo-notes {
|
.todo-notes {
|
||||||
.note-display {
|
display: flex;
|
||||||
h1 {
|
flex: 1;
|
||||||
font-size: 1.8rem;
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
// No `gap` — gap can't transition, so it would snap closed when a row
|
||||||
|
// is removed at the end of the leave animation. Spacing lives on
|
||||||
|
// .todo-li-inner instead so it collapses with the row.
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style: none;
|
||||||
|
// Use grid-template-rows 1fr → 0fr so the row's height collapses
|
||||||
|
// smoothly during leave instead of snapping when the DOM is removed.
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"] {
|
.todo-li-inner {
|
||||||
margin-right: 0.5rem;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-enter-active,
|
||||||
|
.todo-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 220ms ease,
|
||||||
|
grid-template-rows 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-enter-from,
|
||||||
|
.todo-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-12px);
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-move {
|
||||||
|
transition: transform 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-filter-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-filter-column {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-filter-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-filter-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-filter-chip {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user