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:
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user