Initial commit: note-taking/task-tracking app with LLM integration scaffold

Vue 3 + TypeScript frontend with Pinia stores, markdown rendering (marked + DOMPurify),
wikilink/tag linkification, and autocomplete. Quart async backend with SQLAlchemy 2.0,
PostgreSQL ARRAY columns, task-note companion linking, backlinks, and note-to-task
conversion. Docker Compose setup with PostgreSQL 16 and Ollama.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 23:35:44 -05:00
commit 22a3a3c1d1
71 changed files with 7173 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
import { ref, type Ref } from "vue";
import { apiGet } from "@/api/client";
import type { NoteListResponse } from "@/types/note";
export interface AutocompleteItem {
label: string;
value: string;
}
export function useAutocomplete(
textareaRef: Ref<HTMLTextAreaElement | null>,
textRef: Ref<string>,
fetchTags: (q: string) => Promise<string[]>
) {
const acItems = ref<AutocompleteItem[]>([]);
const acVisible = ref(false);
const acIndex = ref(0);
const acTop = ref(0);
const acLeft = ref(0);
let acTriggerStart = -1;
let acType: "tag" | "wikilink" | null = null;
let debounceTimer: ReturnType<typeof setTimeout>;
function detectTrigger() {
const el = textareaRef.value;
if (!el) return;
const cursor = el.selectionStart;
const text = textRef.value;
// Check for [[ trigger
for (let i = cursor - 1; i >= 1; i--) {
if (text[i] === "\n") break;
if (text[i - 1] === "[" && text[i] === "[") {
const query = text.slice(i + 1, cursor);
if (!/\]/.test(query)) {
acTriggerStart = i - 1;
acType = "wikilink";
debouncedSearch(query);
return;
}
break;
}
}
// Check for # trigger
for (let i = cursor - 1; i >= 0; i--) {
const ch = text[i];
if (ch === "\n" || ch === " " || ch === "\t") break;
if (ch === "#") {
// Must be start of line or preceded by whitespace
if (i === 0 || /\s/.test(text[i - 1])) {
const query = text.slice(i + 1, cursor);
if (/^[\w/]*$/.test(query)) {
acTriggerStart = i;
acType = "tag";
debouncedSearch(query);
return;
}
}
break;
}
}
dismiss();
}
function debouncedSearch(query: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(query), 200);
}
async function doSearch(query: string) {
if (acType === "tag") {
try {
const tags = await fetchTags(query);
acItems.value = tags.slice(0, 8).map((t) => ({
label: `#${t}`,
value: t,
}));
} catch (e) {
console.error("Tag autocomplete fetch failed:", e);
acItems.value = [];
}
} else if (acType === "wikilink") {
try {
const data = await apiGet<NoteListResponse>(
`/api/notes?q=${encodeURIComponent(query)}&limit=8`
);
acItems.value = data.notes.map((n) => ({
label: n.title || "Untitled",
value: n.title || "Untitled",
}));
} catch (e) {
console.error("Wikilink autocomplete fetch failed:", e);
acItems.value = [];
}
}
if (acItems.value.length > 0) {
acIndex.value = 0;
positionDropdown();
acVisible.value = true;
} else if (acType === "tag") {
// Show "no tags" hint so the user knows the trigger is working
acItems.value = [{ label: "No tags yet", value: "" }];
acIndex.value = -1;
positionDropdown();
acVisible.value = true;
} else {
acVisible.value = false;
}
}
function positionDropdown() {
const el = textareaRef.value;
if (!el) return;
// Use a mirror div to measure cursor position
const mirror = document.createElement("div");
const style = getComputedStyle(el);
const props = [
"fontFamily",
"fontSize",
"fontWeight",
"lineHeight",
"letterSpacing",
"wordSpacing",
"textIndent",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"boxSizing",
"whiteSpace",
"wordWrap",
"overflowWrap",
] as const;
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.overflow = "hidden";
mirror.style.width = style.width;
for (const prop of props) {
mirror.style[prop] = style[prop];
}
const textBefore = el.value.slice(0, el.selectionStart);
mirror.textContent = textBefore;
const span = document.createElement("span");
span.textContent = "|";
mirror.appendChild(span);
document.body.appendChild(mirror);
const rect = el.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
const mirrorRect = mirror.getBoundingClientRect();
acTop.value =
rect.top + (spanRect.top - mirrorRect.top) - el.scrollTop + 20;
acLeft.value =
rect.left + (spanRect.left - mirrorRect.left) - el.scrollLeft;
document.body.removeChild(mirror);
}
function accept(index?: number) {
const el = textareaRef.value;
if (!el || !acVisible.value) return;
const idx = index ?? acIndex.value;
const item = acItems.value[idx];
if (!item || !item.value) return;
const cursor = el.selectionStart;
const text = textRef.value;
let replacement: string;
let end = cursor;
if (acType === "tag") {
replacement = `#${item.value} `;
} else {
replacement = `[[${item.value}]]`;
}
textRef.value =
text.slice(0, acTriggerStart) + replacement + text.slice(end);
const newPos = acTriggerStart + replacement.length;
dismiss();
// Restore cursor position after Vue updates the DOM
requestAnimationFrame(() => {
el.setSelectionRange(newPos, newPos);
el.focus();
});
}
function dismiss() {
acVisible.value = false;
acItems.value = [];
acType = null;
acTriggerStart = -1;
clearTimeout(debounceTimer);
}
function onKeydown(e: KeyboardEvent): boolean {
if (!acVisible.value) return false;
if (e.key === "ArrowDown") {
e.preventDefault();
acIndex.value = (acIndex.value + 1) % acItems.value.length;
return true;
}
if (e.key === "ArrowUp") {
e.preventDefault();
acIndex.value =
(acIndex.value - 1 + acItems.value.length) % acItems.value.length;
return true;
}
if (e.key === "Tab") {
e.preventDefault();
// Single match: accept immediately. Multiple: cycle then accept on second Tab.
if (acItems.value.length === 1) {
accept();
} else {
acIndex.value = (acIndex.value + 1) % acItems.value.length;
}
return true;
}
if (e.key === "Enter") {
e.preventDefault();
accept();
return true;
}
if (e.key === "Escape") {
e.preventDefault();
dismiss();
return true;
}
return false;
}
return {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept,
dismiss,
onKeydown,
};
}
@@ -0,0 +1,11 @@
export function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
+29
View File
@@ -0,0 +1,29 @@
import { ref } from "vue";
type Theme = "light" | "dark";
const theme = ref<Theme>(getInitialTheme());
function getInitialTheme(): Theme {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
if (window.matchMedia("(prefers-color-scheme: light)").matches) return "light";
return "dark";
}
function applyTheme(t: Theme) {
document.documentElement.setAttribute("data-theme", t);
localStorage.setItem("theme", t);
}
export function useTheme() {
// Apply on first use
applyTheme(theme.value);
function toggleTheme() {
theme.value = theme.value === "dark" ? "light" : "dark";
applyTheme(theme.value);
}
return { theme, toggleTheme };
}