Add invitation system, table of contents, actionable dashboard, and settings improvements

Invitation system (Phase 5.7):
- invitation_tokens table (migration 0012) with SHA256-hashed tokens, 7-day expiry
- Admin CRUD endpoints: POST/GET/DELETE /api/admin/invitations
- Branded invitation email with registration link
- /register-invite frontend view with token validation and account creation
- Admin UI: invite form + pending invitations table with revoke
- Configurable base URL setting for email links (replaces hardcoded Config.BASE_URL)

Table of contents + markdown improvements (Phase 5.8):
- TableOfContents component: sticky sidebar, heading parsing, smooth-scroll
- Custom marked renderer adds id attributes to headings for anchor links
- stripFirstLineTags() prevents leading #tags from rendering as headings
- NoteViewerView/TaskViewerView flex layout with TOC sidebar (hidden ≤1200px)
- Model catalog refresh: added llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1,
  qwen2.5-coder, dolphin3; added Reasoning category; removed discontinued models
- Settings model section split into Installed/Available tabs

Actionable dashboard (Phase 5.9):
- due_before/due_after query params on /api/tasks (exclusive < / inclusive >=)
- HomeView rewritten: overdue (red accent), due today, in progress, chats, notes
- 5 parallel API calls via Promise.allSettled
- Client-side done filtering and in-progress deduplication
- Task sections hidden when empty; status toggle removes done tasks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 08:46:51 -05:00
parent d354da5b51
commit e02b681e91
20 changed files with 1506 additions and 295 deletions
+32 -2
View File
@@ -8,8 +8,37 @@ function decodeEntities(text: string): string {
return textarea.value;
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/<[^>]+>/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
}
export function stripFirstLineTags(text: string): string {
const match = text.match(/^[ \t]*((?:(?<!\w)#[\w]+(?:\/[\w]+)*[ \t]*)+)\n?/);
if (!match) return text;
// Verify every #-token is a tag, not a heading (headings have "# " with space)
const tokens = match[1].trim().split(/\s+/);
const allTags = tokens.every((t) => /^#[\w]+(?:\/[\w]+)*$/.test(t));
if (!allTags) return text;
return text.slice(match[0].length);
}
const headingRenderer = {
heading({ text, depth }: { text: string; depth: number }): string {
const id = slugify(text);
return `<h${depth} id="${id}">${text}</h${depth}>`;
},
};
marked.use({ renderer: headingRenderer });
export function renderMarkdown(text: string): string {
const decoded = decodeEntities(text);
const stripped = stripFirstLineTags(text);
const decoded = decodeEntities(stripped);
const html = marked(decoded) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
@@ -21,7 +50,8 @@ export function renderMarkdown(text: string): string {
}
export function renderPreview(text: string): string {
const decoded = decodeEntities(text);
const stripped = stripFirstLineTags(text);
const decoded = decodeEntities(stripped);
const html = marked(decoded) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);