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:
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) }}
|
||||
·
|
||||
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) }}
|
||||
·
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }}
|
||||
·
|
||||
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) }}
|
||||
·
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user