feat(todo): switch /todo view to todo.txt format
Some checks failed
CI / verify (push) Failing after 1m1s
Some checks failed
CI / verify (push) Failing after 1m1s
Replace the markdown _todo/todo.md view with a structured todo.txt UI at the repo root: priority dropdown, inline body edit, project/context/due/rec chips with Tabler icons, add input as a join-suffix, project/context filter columns with TransitionGroup animation, priority-sorted list. Drops the now-unused .todo-notes markdown checkbox styles from app.css.
This commit is contained in:
470
src/components/TodoTxtItem.vue
Normal file
470
src/components/TodoTxtItem.vue
Normal file
@@ -0,0 +1,470 @@
|
||||
<script setup lang="ts">
|
||||
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 priorityBadgeClass = computed(() => {
|
||||
switch (props.task.priority) {
|
||||
case "A":
|
||||
return "badge badge-soft badge-error"
|
||||
case "B":
|
||||
return "badge badge-soft badge-warning"
|
||||
case "C":
|
||||
return "badge badge-soft badge-info"
|
||||
default:
|
||||
return "badge badge-soft badge-ghost"
|
||||
}
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
if (!props.canEdit) return
|
||||
emit("update", toggleCompleted(props.task))
|
||||
}
|
||||
|
||||
const setPriority = (priority: string | undefined) => {
|
||||
if (!props.canEdit) return
|
||||
emit("update", { ...props.task, priority })
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
const dropdownRef = useTemplateRef<HTMLDetailsElement>("priorityDropdown")
|
||||
const closeDropdown = () => {
|
||||
if (dropdownRef.value) dropdownRef.value.open = 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"
|
||||
/>
|
||||
|
||||
<details ref="priorityDropdown" class="dropdown dropdown-bottom">
|
||||
<summary
|
||||
:class="priorityBadgeClass"
|
||||
class="cursor-pointer list-none w-7 justify-center mt-0.5"
|
||||
:tabindex="canEdit ? 0 : -1"
|
||||
>
|
||||
{{ task.priority ?? "—" }}
|
||||
</summary>
|
||||
<ul
|
||||
v-if="canEdit"
|
||||
class="dropdown-content menu bg-base-200 rounded-box z-10 shadow p-1 mt-1"
|
||||
>
|
||||
<li v-for="p in PRIORITIES" :key="p">
|
||||
<a @click="setPriority(p)">({{ p }})</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="setPriority(undefined)">none</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<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
|
||||
>
|
||||
+{{ 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
|
||||
>
|
||||
@{{ 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-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;
|
||||
}
|
||||
|
||||
/* 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"] *)) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -1,80 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, ref, watch } from "vue"
|
||||
import { computed, defineAsyncComponent, ref, watch } from "vue"
|
||||
|
||||
import { useCheckboxCommit } from "@/hooks/useCheckboxCommit.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import TodoTxtItem from "@/components/TodoTxtItem.vue"
|
||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||
import { useTodoTxtCommit } from "@/hooks/useTodoTxtCommit.hook"
|
||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
||||
import { errorMessage } from "@/utils/notif"
|
||||
import {
|
||||
contextsOf,
|
||||
FileLine,
|
||||
isBlank,
|
||||
parseLine,
|
||||
projectsOf,
|
||||
Task
|
||||
} from "@/utils/todotxt"
|
||||
|
||||
type Prop = {
|
||||
user: string
|
||||
repo: string
|
||||
}
|
||||
|
||||
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
|
||||
const props = defineProps<Prop>()
|
||||
const user = computed(() => props.user)
|
||||
const repo = computed(() => props.repo)
|
||||
const TODO_PATH = "todo.txt"
|
||||
|
||||
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
|
||||
|
||||
const props = defineProps<Prop>()
|
||||
const store = useUserRepoStore()
|
||||
|
||||
const todoNote = computed(() =>
|
||||
store.files.find((file) => file.path?.endsWith("_todo/todo.md"))
|
||||
)
|
||||
|
||||
const sha = computed(() => todoNote.value?.sha ?? "")
|
||||
const path = computed(() => todoNote.value?.path)
|
||||
|
||||
const todoFile = computed(() => store.files.find((f) => f.path === TODO_PATH))
|
||||
const sha = computed(() => todoFile.value?.sha ?? "")
|
||||
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 } =
|
||||
useCheckboxCommit({
|
||||
const { items, syncContent, mutate, hasPendingChanges } = useTodoTxtCommit({
|
||||
user: props.user,
|
||||
repo: props.repo,
|
||||
path,
|
||||
initialContent: "",
|
||||
initialSha: sha,
|
||||
containerSelector: ".todo-notes .note-display",
|
||||
debounceMs: 1000,
|
||||
enabled: canPush
|
||||
debounceMs: 1000
|
||||
})
|
||||
|
||||
// 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(
|
||||
sha,
|
||||
async (newSha) => {
|
||||
if (!newSha || hasPendingChanges.value) {
|
||||
return
|
||||
}
|
||||
const base64Content = await queryFileContent(props.user, props.repo, newSha)
|
||||
if (base64Content) {
|
||||
const rawContent = decodeBase64ToUTF8(base64Content)
|
||||
syncContent(rawContent, newSha)
|
||||
if (!newSha || hasPendingChanges.value) return
|
||||
const base64 = await queryFileContent(props.user, props.repo, newSha)
|
||||
if (base64) {
|
||||
syncContent(decodeBase64ToUTF8(base64), newSha)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Setup checkbox listeners when content renders or canPush changes
|
||||
watch(
|
||||
[renderedContent, canPush],
|
||||
async () => {
|
||||
await nextTick()
|
||||
listenToCheckboxes()
|
||||
},
|
||||
{ immediate: true }
|
||||
// Rank `A` = 65 .. `Z` = 90; absent priority is Infinity so it sorts last
|
||||
// regardless of locale collation quirks.
|
||||
const priorityRank = (p?: string): number =>
|
||||
p ? p.charCodeAt(0) : Number.POSITIVE_INFINITY
|
||||
|
||||
const taskEntries = computed(() => {
|
||||
const out: { line: Task; index: number }[] = []
|
||||
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()
|
||||
})
|
||||
|
||||
const activeProjects = ref<Set<string>>(new Set())
|
||||
const activeContexts = ref<Set<string>>(new Set())
|
||||
|
||||
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 clearFilters = () => {
|
||||
activeProjects.value = new Set()
|
||||
activeContexts.value = new Set()
|
||||
}
|
||||
|
||||
const hasFilters = computed(
|
||||
() => activeProjects.value.size > 0 || activeContexts.value.size > 0
|
||||
)
|
||||
|
||||
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))
|
||||
return projectsOk && contextsOk
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -83,22 +183,228 @@ watch(
|
||||
key="todo-notes"
|
||||
:user="user"
|
||||
:repo="repo"
|
||||
:content="renderedContent"
|
||||
content=""
|
||||
: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="Add a task ((A) priority +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"
|
||||
class="todo-filters"
|
||||
>
|
||||
<div class="todo-filter-columns">
|
||||
<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-xs w-fit 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">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style scoped lang="scss">
|
||||
.todo-notes {
|
||||
.note-display {
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
position: relative;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
.todo-enter-active,
|
||||
.todo-leave-active {
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.todo-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
|
||||
.todo-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
// Take the leaving item out of layout flow so the rest of the list
|
||||
// slides up smoothly via .todo-move instead of jumping.
|
||||
.todo-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.todo-move {
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
|
||||
.todo-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.todo-filter-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.todo-filter-column {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.todo-filter-label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin: 0 0 0.35rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.todo-filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.todo-filter-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.todo-filter-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user