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
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from "vue";
import { slugify } from "@/utils/markdown";
const props = defineProps<{ body: string }>();
interface TocEntry {
depth: number;
text: string;
id: string;
}
const headings = computed<TocEntry[]>(() => {
const entries: TocEntry[] = [];
const re = /^(#{1,6})\s+(.+)$/gm;
let match;
while ((match = re.exec(props.body)) !== null) {
entries.push({
depth: match[1].length,
text: match[2].trim(),
id: slugify(match[2].trim()),
});
}
return entries;
});
function scrollTo(id: string) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
</script>
<template>
<nav v-if="headings.length" class="toc">
<h3 class="toc-title">Contents</h3>
<ul class="toc-list">
<li
v-for="(h, i) in headings"
:key="i"
:style="{ paddingLeft: (h.depth - 1) * 0.75 + 'rem' }"
>
<a class="toc-link" @click.prevent="scrollTo(h.id)" :href="`#${h.id}`">
{{ h.text }}
</a>
</li>
</ul>
</nav>
</template>
<style scoped>
.toc {
position: sticky;
top: 1rem;
width: 200px;
flex-shrink: 0;
align-self: flex-start;
font-size: 0.85rem;
line-height: 1.4;
}
.toc-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--color-text-muted);
margin: 0 0 0.5rem;
letter-spacing: 0.05em;
}
.toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.toc-list li {
margin-bottom: 0.25rem;
}
.toc-link {
color: var(--color-text-muted);
text-decoration: none;
cursor: pointer;
}
.toc-link:hover {
color: var(--color-primary);
}
</style>
+6
View File
@@ -34,6 +34,12 @@ const router = createRouter({
component: () => import("@/views/ResetPasswordView.vue"),
meta: { public: true },
},
{
path: "/register-invite",
name: "register-invite",
component: () => import("@/views/RegisterInviteView.vue"),
meta: { public: true },
},
{
path: "/notes",
name: "notes",
+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);
+122 -43
View File
@@ -13,47 +13,96 @@ import { useChatStore } from "@/stores/chat";
const router = useRouter();
const recentNotes = ref<Note[]>([]);
const recentTasks = ref<Task[]>([]);
const overdueTasks = ref<Task[]>([]);
const dueTodayTasks = ref<Task[]>([]);
const inProgressTasks = ref<Task[]>([]);
const recentChats = ref<Conversation[]>([]);
const loading = ref(true);
const tasksStore = useTasksStore();
function todayStr(): string {
const d = new Date();
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
function tomorrowStr(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
onMounted(async () => {
try {
const notesData = await apiGet<NoteListResponse>(
"/api/notes?sort=updated_at&order=desc&limit=5"
);
recentNotes.value = notesData.notes;
} catch (e) {
console.error("Failed to load recent notes:", e);
const today = todayStr();
const tomorrow = tomorrowStr();
const [notesRes, overdueRes, dueTodayRes, inProgressRes, chatsRes] =
await Promise.allSettled([
apiGet<NoteListResponse>("/api/notes?sort=updated_at&order=desc&limit=5"),
apiGet<TaskListResponse>(
`/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=15`
),
apiGet<TaskListResponse>(
`/api/tasks?due_after=${today}&due_before=${tomorrow}&sort=priority&order=desc&limit=10`
),
apiGet<TaskListResponse>(
`/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=5`
),
apiGet<{ conversations: Conversation[]; total: number }>(
"/api/chat/conversations?limit=3&offset=0"
),
]);
if (notesRes.status === "fulfilled") {
recentNotes.value = notesRes.value.notes;
}
if (chatsRes.status === "fulfilled") {
recentChats.value = chatsRes.value.conversations;
}
try {
const tasksData = await apiGet<TaskListResponse>(
"/api/tasks?sort=updated_at&order=desc&limit=5"
// Filter out done tasks from overdue and due-today
if (overdueRes.status === "fulfilled") {
overdueTasks.value = overdueRes.value.tasks.filter(
(t) => t.status !== "done"
);
}
if (dueTodayRes.status === "fulfilled") {
dueTodayTasks.value = dueTodayRes.value.tasks.filter(
(t) => t.status !== "done"
);
recentTasks.value = tasksData.tasks;
} catch (e) {
console.error("Failed to load recent tasks:", e);
}
try {
const chatData = await apiGet<{ conversations: Conversation[]; total: number }>(
"/api/chat/conversations?limit=3&offset=0"
// Deduplicate in-progress against overdue and due-today
if (inProgressRes.status === "fulfilled") {
const seen = new Set<number>();
for (const t of overdueTasks.value) seen.add(t.id);
for (const t of dueTodayTasks.value) seen.add(t.id);
inProgressTasks.value = inProgressRes.value.tasks.filter(
(t) => !seen.has(t.id)
);
recentChats.value = chatData.conversations;
} catch (e) {
console.error("Failed to load recent chats:", e);
}
loading.value = false;
});
function removeFromAllLists(id: number) {
overdueTasks.value = overdueTasks.value.filter((t) => t.id !== id);
dueTodayTasks.value = dueTodayTasks.value.filter((t) => t.id !== id);
inProgressTasks.value = inProgressTasks.value.filter((t) => t.id !== id);
}
function onStatusToggle(id: number, status: TaskStatus) {
tasksStore.patchStatus(id, status).then((updated) => {
const idx = recentTasks.value.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
recentTasks.value[idx] = updated;
if (updated.status === "done") {
removeFromAllLists(updated.id);
} else {
// Update in whichever list contains it
for (const list of [overdueTasks, dueTodayTasks, inProgressTasks]) {
const idx = list.value.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
list.value[idx] = updated;
break;
}
}
}
});
}
@@ -73,6 +122,51 @@ async function newChat() {
<p v-if="loading" class="loading">Loading...</p>
<template v-else>
<section v-if="overdueTasks.length" class="section section-overdue">
<div class="section-header">
<h2>Overdue</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in overdueTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="dueTodayTasks.length" class="section">
<div class="section-header">
<h2>Due Today</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in dueTodayTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="inProgressTasks.length" class="section">
<div class="section-header">
<h2>In Progress</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in inProgressTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recent Chats</h2>
@@ -97,7 +191,7 @@ async function newChat() {
<section class="section">
<div class="section-header">
<h2>Recent Notes</h2>
<h2>Recently Edited</h2>
<router-link to="/notes" class="see-all">See all</router-link>
</div>
<div v-if="recentNotes.length" class="cards">
@@ -112,25 +206,6 @@ async function newChat() {
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recent Tasks</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div v-if="recentTasks.length" class="cards">
<TaskCard
v-for="task in recentTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
<div v-else class="empty-state">
<p class="empty-text">No tasks yet.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
</div>
</section>
</template>
</main>
</template>
@@ -150,6 +225,10 @@ async function newChat() {
.section {
margin-bottom: 2rem;
}
.section-overdue {
border-left: 3px solid var(--color-overdue, #e74c3c);
padding-left: 0.75rem;
}
.section-header {
display: flex;
justify-content: space-between;
+82 -58
View File
@@ -7,6 +7,7 @@ import { relativeTime } from "@/composables/useRelativeTime";
import { apiPost } from "@/api/client";
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
const route = useRoute();
const router = useRouter();
@@ -100,72 +101,95 @@ async function convertToTask() {
</script>
<template>
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentNote">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<router-link
:to="`/notes/${store.currentNote.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
v-if="!store.currentNote.is_task"
class="btn-convert"
@click="convertToTask"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Task" }}
</button>
</div>
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentNote.updated_at) }}
&middot;
Created {{ relativeTime(store.currentNote.created_at) }}
</p>
<div class="tags" v-if="store.currentNote.tags.length">
<TagPill
v-for="tag in store.currentNote.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
ref="bodyEl"
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div class="viewer-layout">
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentNote">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<router-link
:to="`/notes/${store.currentNote.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
v-if="!store.currentNote.is_task"
class="btn-convert"
@click="convertToTask"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Task" }}
</button>
</div>
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentNote.updated_at) }}
&middot;
Created {{ relativeTime(store.currentNote.created_at) }}
</p>
<div class="tags" v-if="store.currentNote.tags.length">
<TagPill
v-for="tag in store.currentNote.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
ref="bodyEl"
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Note not found.</p>
</main>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Note not found.</p>
</main>
<TableOfContents
v-if="store.currentNote?.body"
:body="store.currentNote.body"
class="toc-sidebar"
/>
</div>
</template>
<style scoped>
.viewer-layout {
display: flex;
max-width: 1200px;
margin: 0 auto;
gap: 2rem;
}
.viewer {
flex: 1;
min-width: 0;
max-width: 960px;
margin: 2rem auto;
margin: 2rem 0;
padding: 0 1rem;
}
.toc-sidebar {
margin-top: 2rem;
}
@media (max-width: 1200px) {
.toc-sidebar {
display: none;
}
}
.toolbar {
display: flex;
gap: 0.75rem;
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const token = computed(() => (route.query.token as string) || "");
const email = ref("");
const username = ref("");
const password = ref("");
const confirmPassword = ref("");
const error = ref("");
const submitting = ref(false);
const validating = ref(true);
const valid = ref(false);
const passwordMismatch = computed(
() => confirmPassword.value.length > 0 && password.value !== confirmPassword.value
);
const canSubmit = computed(
() =>
!submitting.value &&
!passwordMismatch.value &&
username.value.trim().length > 0 &&
password.value.length >= 8 &&
!!token.value
);
onMounted(async () => {
if (!token.value) {
validating.value = false;
return;
}
try {
const data = await apiGet<{ valid: boolean; email?: string }>(
`/api/auth/invitation/${token.value}`
);
valid.value = data.valid;
if (data.email) {
email.value = data.email;
}
} catch {
valid.value = false;
} finally {
validating.value = false;
}
});
async function handleSubmit() {
if (passwordMismatch.value) {
error.value = "Passwords do not match";
return;
}
error.value = "";
submitting.value = true;
try {
await apiPost("/api/auth/register-with-invite", {
token: token.value,
username: username.value.trim(),
password: password.value,
});
await authStore.checkAuth();
router.push("/");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<main class="auth-page">
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
<div v-if="validating" class="loading-msg">Validating invitation...</div>
<div v-else-if="!token || !valid" class="error-block">
<p>This invitation link is invalid or has expired.</p>
<p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link>
</p>
</div>
<template v-else>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="email">Email</label>
<input
id="email"
:value="email"
type="email"
class="input"
disabled
/>
</div>
<div class="field">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="input"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<div class="field">
<label for="confirm-password">Confirm Password</label>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
required
class="input"
:class="{ 'input-error': passwordMismatch }"
/>
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="!canSubmit">
{{ submitting ? "Creating Account..." : "Create Account" }}
</button>
</form>
</template>
<p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link>
</p>
</div>
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.95rem;
padding: 1rem 0;
}
.error-block {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.input-error {
border-color: var(--color-danger);
}
.input-error:focus {
border-color: var(--color-danger);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-danger);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
+246 -97
View File
@@ -46,88 +46,110 @@ const sendingTest = ref(false);
const MODEL_CATALOG: ModelInfo[] = [
// — General Purpose —
{
name: "llama3.2",
description: "Meta's Llama 3.2 — lightweight models (1B/3B) with 128K context. Great for low-resource setups.",
size: "2.0 GB",
bestFor: "Light tasks, fast responses, low RAM",
category: "General Purpose",
},
{
name: "llama3.1",
description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following.",
size: "4.7 GB",
description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following. 128K context.",
size: "4.9 GB",
bestFor: "General chat, writing, Q&A",
category: "General Purpose",
},
{
name: "llama3.1:70b",
description: "Meta's Llama 3.1 70B — significantly more capable, better reasoning and nuance.",
size: "40 GB",
name: "llama3.3",
description: "Meta's Llama 3.3 70B — 405B-level performance in a 70B model. 128K context. Top tier open model.",
size: "43 GB",
bestFor: "Complex reasoning, detailed analysis",
category: "General Purpose",
},
{
name: "mistral",
description: "Mistral 7B — fast and efficient with strong performance for its size.",
size: "4.1 GB",
bestFor: "Fast responses, general tasks",
name: "gemma3",
description: "Google's Gemma 3 — multimodal (vision + text), 1B to 27B. 128K context, 140+ languages.",
size: "5.2 GB",
bestFor: "Multilingual, vision, general tasks",
category: "General Purpose",
},
{
name: "qwen3",
description: "Alibaba's Qwen 3 — latest generation, 0.6B to 235B. Up to 256K context. Hybrid thinking modes.",
size: "4.7 GB",
bestFor: "Multilingual, reasoning, code, math",
category: "General Purpose",
},
{
name: "qwen2.5",
description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills.",
description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills. 32K context.",
size: "4.7 GB",
bestFor: "Multilingual, code, math",
category: "General Purpose",
},
{
name: "phi3",
description: "Microsoft Phi-3 Mini — compact model with surprising capability for its size.",
size: "2.3 GB",
bestFor: "Light tasks, low resource usage",
category: "General Purpose",
},
{
name: "neural-chat",
description: "Intel's fine-tune optimized for natural conversation. Lighter filtering than base models.",
name: "mistral",
description: "Mistral 7B v0.3 — fast and efficient with function calling support. 32K context.",
size: "4.1 GB",
bestFor: "Natural conversation, general tasks",
bestFor: "Fast responses, general tasks",
category: "General Purpose",
},
{
name: "yi",
description: "01.AI's Yi 6B — Chinese-developed model, more permissive on creative content.",
size: "3.5 GB",
bestFor: "Creative content, multilingual",
name: "phi4",
description: "Microsoft Phi-4 14B — strong reasoning and math for its size. Successor to Phi-3.",
size: "9.1 GB",
bestFor: "Reasoning, math, structured tasks",
category: "General Purpose",
},
{
name: "command-r",
description: "Cohere's Command R 35B — enterprise-grade model with light content filtering.",
description: "Cohere's Command R 35B — enterprise-grade model with 128K context and light content filtering.",
size: "20 GB",
bestFor: "RAG, conversation, creative tasks",
category: "General Purpose",
},
// — Reasoning —
{
name: "deepseek-r1",
description: "DeepSeek R1 — chain-of-thought reasoning model. Distilled versions from 1.5B to 70B run locally.",
size: "4.7 GB",
bestFor: "Step-by-step reasoning, math, logic",
category: "Reasoning",
},
// — Coding —
{
name: "codellama",
description: "Meta's Code Llama — specialized for code generation and understanding.",
size: "3.8 GB",
bestFor: "Code generation, debugging, technical docs",
name: "qwen2.5-coder",
description: "Alibaba's Qwen 2.5 Coder — 0.5B to 32B. 32B version rivals GPT-4o on coding benchmarks.",
size: "4.7 GB",
bestFor: "Code generation, refactoring, debugging",
category: "Coding",
},
{
name: "deepseek-coder-v2",
description: "DeepSeek Coder V2 — state-of-the-art coding model with strong math ability.",
description: "DeepSeek Coder V2 16B — strong coding model with 160K context and math ability.",
size: "8.9 GB",
bestFor: "Code, math, technical problem solving",
category: "Coding",
},
// — Uncensored / Creative Writing —
{
name: "dolphin3",
description: "Eric Hartford's Dolphin 3 — next-gen uncensored model based on Llama 3.1 8B. 128K context.",
size: "4.9 GB",
bestFor: "Uncensored chat, coding, creative writing",
category: "Uncensored / Creative Writing",
},
{
name: "dolphin-mistral",
description: "Eric Hartford's Dolphin fine-tune of Mistral 7B. Safety/refusal data removed from training.",
description: "Dolphin fine-tune of Mistral 7B. Safety/refusal data removed from training. 32K context.",
size: "4.1 GB",
bestFor: "Uncensored general chat, creative writing",
category: "Uncensored / Creative Writing",
},
{
name: "dolphin-llama3",
description: "Dolphin fine-tune of Llama 3 8B. Uncensored training on a stronger base model.",
description: "Dolphin fine-tune of Llama 3 8B. Uncensored training on a strong base model.",
size: "4.7 GB",
bestFor: "Uncensored chat, strong reasoning",
category: "Uncensored / Creative Writing",
@@ -153,30 +175,15 @@ const MODEL_CATALOG: ModelInfo[] = [
bestFor: "Helpful assistant, minimal filtering",
category: "Uncensored / Creative Writing",
},
{
name: "nollama/mythomax-l2-13b",
description: "MythoMax L2 13B — a merge of multiple fine-tunes for creative and narrative writing with strong coherence.",
size: "7.4 GB",
bestFor: "Creative fiction, roleplay, narrative writing",
category: "Uncensored / Creative Writing",
},
{
name: "mattw/mythalion",
description: "Mythalion 13B — a Gryphe merge combining Mythologic and Pygmalion for expressive creative output.",
size: "7.4 GB",
bestFor: "Creative writing, character dialogue",
category: "Uncensored / Creative Writing",
},
{
name: "samantha-mistral",
description: "Eric Hartford's Samantha personality model. Designed as a helpful companion without refusals.",
size: "4.1 GB",
bestFor: "Companion chat, unrestricted conversation",
category: "Uncensored / Creative Writing",
},
];
const selectedModel = ref("");
const modelTab = ref<"installed" | "available">("installed");
// Base URL setting (admin only)
const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
const modelStatuses = computed(() => {
const installed = new Set(
@@ -189,6 +196,10 @@ const modelStatuses = computed(() => {
}));
});
const installedModels = computed(() =>
modelStatuses.value.filter((m) => m.installed)
);
const categories = computed(() => {
const cats: string[] = [];
for (const m of modelStatuses.value) {
@@ -224,7 +235,7 @@ onMounted(async () => {
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
}
// Load SMTP config if admin
// Load admin settings
if (authStore.isAdmin) {
try {
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
@@ -232,6 +243,12 @@ onMounted(async () => {
} catch {
// SMTP not configured yet
}
try {
const urlConfig = await apiGet<{ base_url: string }>("/api/admin/base-url");
baseUrl.value = urlConfig.base_url;
} catch {
// base URL not configured yet
}
}
});
@@ -411,6 +428,20 @@ async function sendTestEmail() {
}
}
async function saveBaseUrl() {
savingBaseUrl.value = true;
baseUrlSaved.value = false;
try {
await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() });
baseUrlSaved.value = true;
setTimeout(() => (baseUrlSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save application URL", "error");
} finally {
savingBaseUrl.value = false;
}
}
async function handleRestoreFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
@@ -545,6 +576,29 @@ async function handleRestoreFile(event: Event) {
</div>
</section>
<section v-if="authStore.isAdmin" class="settings-section">
<h2>Application URL</h2>
<p class="section-desc">
The public URL used in email links (invitations, password resets). Example: https://notes.example.com
</p>
<div class="field">
<label for="base-url">Base URL</label>
<input
id="base-url"
v-model="baseUrl"
type="url"
placeholder="https://notes.example.com"
class="input"
/>
</div>
<div class="actions">
<button class="btn-save" @click="saveBaseUrl" :disabled="savingBaseUrl">
{{ savingBaseUrl ? "Saving..." : "Save" }}
</button>
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section v-if="authStore.isAdmin" class="settings-section">
<h2>Email / SMTP</h2>
<p class="section-desc">
@@ -612,15 +666,35 @@ async function handleRestoreFile(event: Event) {
<section class="settings-section">
<h2>Model</h2>
<p class="section-desc">
Choose which LLM model to use for chat. Models need to be downloaded before use.
</p>
<div v-for="cat in categories" :key="cat" class="model-category">
<h3 class="category-label">{{ cat }}</h3>
<div class="model-list">
<div class="model-tabs">
<button
class="model-tab"
:class="{ active: modelTab === 'installed' }"
@click="modelTab = 'installed'"
>
Installed
</button>
<button
class="model-tab"
:class="{ active: modelTab === 'available' }"
@click="modelTab = 'available'"
>
Available
</button>
</div>
<!-- Installed tab -->
<template v-if="modelTab === 'installed'">
<p class="section-desc">
Models currently downloaded and ready to use.
</p>
<div v-if="installedModels.length === 0" class="empty-state">
No models installed. Browse the Available tab to download one.
</div>
<div v-else class="model-list">
<div
v-for="model in modelsInCategory(cat)"
v-for="model in installedModels"
:key="model.name"
class="model-card"
:class="{ active: model.active }"
@@ -630,56 +704,84 @@ async function handleRestoreFile(event: Event) {
<span class="model-name">{{ model.name }}</span>
<span class="model-size">{{ model.size }}</span>
</div>
<p class="model-desc">{{ model.description }}</p>
<p class="model-best-for">
<strong>Best for:</strong> {{ model.bestFor }}
</p>
</div>
<div class="model-actions">
<template v-if="model.installed">
<button
v-if="!model.active"
class="btn-select"
@click="selectModel(model.name)"
:disabled="saving"
>
Select
</button>
<span v-else class="active-badge">Active</span>
<template v-if="confirmDelete === model.name">
<button
v-if="!model.active"
class="btn-select"
@click="selectModel(model.name)"
:disabled="saving"
>
Select
</button>
<span v-else class="active-badge">Active</span>
<template v-if="confirmDelete === model.name">
<button
class="btn-confirm-delete"
@click="removeModel(model.name)"
:disabled="deleting !== null"
>
{{ deleting === model.name ? "Removing..." : "Confirm" }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete">
Cancel
</button>
</template>
<button
v-else
class="btn-remove"
class="btn-confirm-delete"
@click="removeModel(model.name)"
:disabled="deleting !== null || model.active"
:title="model.active ? 'Cannot remove the active model' : 'Remove model'"
:disabled="deleting !== null"
>
Remove
{{ deleting === model.name ? "Removing..." : "Confirm" }}
</button>
<button class="btn-cancel-delete" @click="cancelDelete">
Cancel
</button>
</template>
<button
v-else
class="btn-pull"
@click="pullModel(model.name)"
:disabled="pullProgress !== null"
class="btn-remove"
@click="removeModel(model.name)"
:disabled="deleting !== null || model.active"
:title="model.active ? 'Cannot remove the active model' : 'Remove model'"
>
{{ pullProgress?.model === model.name ? `Downloading ${pullProgress.percent}%` : "Download" }}
Remove
</button>
</div>
</div>
</div>
</div>
</template>
<!-- Available tab -->
<template v-if="modelTab === 'available'">
<p class="section-desc">
Browse and download models. Models need to be downloaded before use.
</p>
<div v-for="cat in categories" :key="cat" class="model-category">
<h3 class="category-label">{{ cat }}</h3>
<div class="model-list">
<div
v-for="model in modelsInCategory(cat)"
:key="model.name"
class="model-card"
:class="{ active: model.active }"
>
<div class="model-info">
<div class="model-name-row">
<span class="model-name">{{ model.name }}</span>
<span class="model-size">{{ model.size }}</span>
</div>
<p class="model-desc">{{ model.description }}</p>
<p class="model-best-for">
<strong>Best for:</strong> {{ model.bestFor }}
</p>
</div>
<div class="model-actions">
<template v-if="model.installed">
<span class="installed-badge">Installed</span>
</template>
<button
v-else
class="btn-pull"
@click="pullModel(model.name)"
:disabled="pullProgress !== null"
>
{{ pullProgress?.model === model.name ? `Downloading ${pullProgress.percent}%` : "Download" }}
</button>
</div>
</div>
</div>
</div>
</template>
</section>
<section class="settings-section">
@@ -912,6 +1014,53 @@ async function handleRestoreFile(event: Event) {
font-weight: 600;
}
/* Model tabs */
.model-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.model-tab {
padding: 0.4rem 1rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
.model-tab.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.model-tab:hover:not(.active) {
border-color: var(--color-text-muted);
color: var(--color-text);
}
/* Installed badge on available tab */
.installed-badge {
padding: 0.25rem 0.75rem;
background: var(--color-success);
color: #fff;
border-radius: var(--radius-lg);
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
/* Empty state */
.empty-state {
padding: 2rem 1rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
}
/* Category headers */
.model-category {
margin-bottom: 1.5rem;
+94 -70
View File
@@ -11,6 +11,7 @@ import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
const route = useRoute();
const router = useRouter();
@@ -118,84 +119,107 @@ function onTagClick(tag: string) {
</script>
<template>
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
class="btn-convert"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
</div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentTask.updated_at) }}
&middot;
Created {{ relativeTime(store.currentTask.created_at) }}
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status!"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority!" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
>
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div class="viewer-layout">
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
class="btn-convert"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
</div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentTask.updated_at) }}
&middot;
Created {{ relativeTime(store.currentTask.created_at) }}
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status!"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority!" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
>
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Task not found.</p>
</main>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Task not found.</p>
</main>
<TableOfContents
v-if="store.currentTask?.body"
:body="store.currentTask.body"
class="toc-sidebar"
/>
</div>
</template>
<style scoped>
.viewer-layout {
display: flex;
max-width: 1200px;
margin: 0 auto;
gap: 2rem;
}
.viewer {
flex: 1;
min-width: 0;
max-width: 960px;
margin: 2rem auto;
margin: 2rem 0;
padding: 0 1rem;
}
.toc-sidebar {
margin-top: 2rem;
}
@media (max-width: 1200px) {
.toc-sidebar {
display: none;
}
}
.toolbar {
display: flex;
gap: 0.75rem;
+159 -2
View File
@@ -1,10 +1,17 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet, apiPut, apiDelete } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { User } from "@/types/auth";
interface Invitation {
id: number;
email: string;
created_at: string;
expires_at: string;
}
const authStore = useAuthStore();
const toastStore = useToastStore();
@@ -15,8 +22,13 @@ const toggling = ref(false);
const confirmDeleteId = ref<number | null>(null);
const deleting = ref<number | null>(null);
const inviteEmail = ref("");
const sendingInvite = ref(false);
const invitations = ref<Invitation[]>([]);
const revokingId = ref<number | null>(null);
onMounted(async () => {
await Promise.all([fetchUsers(), fetchRegistration()]);
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
loading.value = false;
});
@@ -38,6 +50,49 @@ async function fetchRegistration() {
}
}
async function fetchInvitations() {
try {
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
invitations.value = data.invitations;
} catch {
// Ignore
}
}
async function sendInvite() {
const email = inviteEmail.value.trim().toLowerCase();
if (!email) return;
sendingInvite.value = true;
try {
await apiPost("/api/admin/invitations", { email });
toastStore.show(`Invitation sent to ${email}`);
inviteEmail.value = "";
await fetchInvitations();
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send invitation", "error");
} else {
toastStore.show("Failed to send invitation", "error");
}
} finally {
sendingInvite.value = false;
}
}
async function revokeInvitation(id: number) {
revokingId.value = id;
try {
await apiDelete(`/api/admin/invitations/${id}`);
invitations.value = invitations.value.filter((inv) => inv.id !== id);
toastStore.show("Invitation revoked");
} catch {
toastStore.show("Failed to revoke invitation", "error");
} finally {
revokingId.value = null;
}
}
async function toggleRegistration() {
toggling.value = true;
try {
@@ -122,6 +177,58 @@ function formatDate(iso: string): string {
</div>
</section>
<section class="settings-section">
<h2>Invite User</h2>
<form class="invite-form" @submit.prevent="sendInvite">
<input
v-model="inviteEmail"
type="email"
placeholder="Email address"
class="input invite-input"
required
:disabled="sendingInvite"
/>
<button
type="submit"
class="btn-invite"
:disabled="sendingInvite || !inviteEmail.trim()"
>
{{ sendingInvite ? "Sending..." : "Send Invite" }}
</button>
</form>
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
<div v-if="invitations.length > 0" class="invite-list">
<h3>Pending Invitations</h3>
<table class="users-table">
<thead>
<tr>
<th>Email</th>
<th class="hide-mobile">Sent</th>
<th class="hide-mobile">Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button
class="btn-delete"
@click="revokeInvitation(inv.id)"
:disabled="revokingId !== null"
>
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="settings-section">
<h2>Users</h2>
@@ -201,6 +308,53 @@ function formatDate(iso: string): string {
font-size: 1.1rem;
}
/* Invite form */
.invite-form {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.invite-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.invite-input:focus {
outline: none;
border-color: var(--color-primary);
}
.btn-invite {
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.btn-invite:disabled {
opacity: 0.6;
cursor: default;
}
.btn-invite:hover:not(:disabled) {
opacity: 0.9;
}
.invite-list {
margin-top: 1rem;
}
.invite-list h3 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
color: var(--color-text-secondary);
}
/* Registration toggle */
.registration-row {
display: flex;
@@ -382,5 +536,8 @@ function formatDate(iso: string): string {
.btn-toggle {
width: 100%;
}
.invite-form {
flex-direction: column;
}
}
</style>