Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation
## Projects & Milestones (Phases A + G) - New models: Project, Milestone (Project → Milestone → Task hierarchy) - notes table: project_id + milestone_id FKs; parent_id FK constraint activated - Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones) - Services: projects.py, milestones.py (CRUD + progress tracking) - Routes: /api/projects + /api/projects/<id>/milestones - LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools - Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated ## RAG Auto-injection (Phase B) - Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each) - excluded_note_ids param; ChatView "Auto-included" sidebar section ## Summarisation improvements (Phase C) - Threshold 20→30, keep-recent 6→8, max_tokens 200→400 - Two-pass summarisation for histories >50 messages ## Browser push notifications (Phase E) - PushSubscription model + migration; pywebpush dependency - /api/push routes; VAPID config; fire-and-forget on generation complete - Frontend: sw.js, push store, Settings toggle ## PWA manifest (Phase F) - manifest.json, Apple meta tags, service worker registration in main.ts ## Tag normalisation - All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize) - Note/Task types gain project_id + milestone_id fields; store signatures updated ## CalDAV - Radicale embedded server reverted; back to user-configured external CalDAV Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,7 @@ router.afterEach(() => {
|
||||
|
||||
<div class="nav-links" :class="{ open: mobileMenuOpen }">
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" class="nav-link">Chat</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
|
||||
interface Milestone {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number | null;
|
||||
modelValue: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: number | null): void;
|
||||
}>();
|
||||
|
||||
const milestones = ref<Milestone[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
async (pid) => {
|
||||
milestones.value = [];
|
||||
if (!pid) {
|
||||
emit("update:modelValue", null);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ milestones: Milestone[] }>(
|
||||
`/api/projects/${pid}/milestones`
|
||||
);
|
||||
milestones.value = data.milestones.filter((m) => m.status === "active");
|
||||
} catch {
|
||||
milestones.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function onChange(e: Event) {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
emit("update:modelValue", val ? Number(val) : null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="milestone-select"
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="!projectId || loading"
|
||||
@change="onChange"
|
||||
>
|
||||
<option value="">{{ projectId ? "No milestone" : "(Select project first)" }}</option>
|
||||
<option v-for="m in milestones" :key="m.id" :value="m.id">
|
||||
{{ m.title }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.milestone-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.milestone-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.milestone-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
modelValue: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: number | null): void;
|
||||
}>();
|
||||
|
||||
const projects = ref<Project[]>([]);
|
||||
let cacheExpiry = 0;
|
||||
|
||||
async function loadProjects() {
|
||||
const now = Date.now();
|
||||
if (projects.value.length > 0 && now < cacheExpiry) return;
|
||||
try {
|
||||
const data = await apiGet<{ projects: Project[] }>("/api/projects?status=active");
|
||||
projects.value = data.projects;
|
||||
cacheExpiry = now + 60_000; // 60 second cache
|
||||
} catch {
|
||||
// Silently fail — selector will just be empty
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProjects);
|
||||
|
||||
function onChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
emit("update:modelValue", value ? Number(value) : null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select class="project-selector" :value="modelValue ?? ''" @change="onChange">
|
||||
<option value="">No project</option>
|
||||
<option v-for="project in projects" :key="project.id" :value="project.id">
|
||||
{{ project.title }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-selector {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.project-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@ const inputRef = ref<HTMLInputElement | null>(null);
|
||||
let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function sanitize(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, "-").replace(/^#+/, "");
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, "-").replace(/^#+/, "");
|
||||
}
|
||||
|
||||
function addTag(raw: string) {
|
||||
|
||||
@@ -9,3 +9,12 @@ const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
|
||||
// Register service worker for push notifications and PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW registration failed — app still works, just no push notifications
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,6 +60,16 @@ const router = createRouter({
|
||||
name: "note-edit",
|
||||
component: () => import("@/views/NoteEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/projects",
|
||||
name: "projects",
|
||||
component: () => import("@/views/ProjectListView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/projects/:id",
|
||||
name: "project-view",
|
||||
component: () => import("@/views/ProjectView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
name: "tasks",
|
||||
|
||||
@@ -123,6 +123,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
includeNoteIds?: number[],
|
||||
think = false,
|
||||
contextNoteTitle?: string,
|
||||
excludeNoteIds?: number[],
|
||||
) {
|
||||
if (!currentConversation.value) return;
|
||||
|
||||
@@ -169,6 +170,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
content,
|
||||
context_note_id: contextNoteId,
|
||||
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
|
||||
excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
|
||||
think,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -78,6 +78,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
title: string;
|
||||
body: string;
|
||||
tags?: string[];
|
||||
project_id?: number | null;
|
||||
}): Promise<Note> {
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
@@ -89,7 +90,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags">>
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id">>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
export const usePushStore = defineStore('push', () => {
|
||||
const isSupported = ref(
|
||||
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
)
|
||||
const permission = ref<NotificationPermission>(
|
||||
'Notification' in window ? Notification.permission : 'denied'
|
||||
)
|
||||
const isSubscribed = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function checkSubscription(): Promise<void> {
|
||||
if (!isSupported.value) return
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
isSubscribed.value = !!sub
|
||||
} catch {
|
||||
isSubscribed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(): Promise<void> {
|
||||
if (!isSupported.value) {
|
||||
error.value = 'Push notifications not supported in this browser'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// Request permission
|
||||
permission.value = await Notification.requestPermission()
|
||||
if (permission.value !== 'granted') {
|
||||
error.value = 'Notification permission denied'
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch VAPID public key
|
||||
const resp = await fetch('/api/push/vapid-public-key')
|
||||
if (!resp.ok) {
|
||||
error.value = 'Push notifications not configured on server'
|
||||
return
|
||||
}
|
||||
const { publicKey } = await resp.json()
|
||||
|
||||
// Register service worker
|
||||
const reg = await navigator.serviceWorker.register('/sw.js')
|
||||
await navigator.serviceWorker.ready
|
||||
|
||||
// Subscribe
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
|
||||
// Save to server
|
||||
const saveResp = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
if (!saveResp.ok) throw new Error('Failed to save subscription')
|
||||
isSubscribed.value = true
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to subscribe'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
if (sub) {
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
})
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
isSubscribed.value = false
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to unsubscribe'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { isSupported, permission, isSubscribed, loading, error, checkSubscription, subscribe, unsubscribe }
|
||||
})
|
||||
@@ -69,6 +69,9 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
due_date?: string | null;
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
parent_id?: number | null;
|
||||
}): Promise<Task> {
|
||||
try {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
@@ -81,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
async function updateTask(
|
||||
id: number,
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date">
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
|
||||
>
|
||||
): Promise<Task> {
|
||||
try {
|
||||
|
||||
@@ -54,7 +54,8 @@ export interface ConversationDetail extends Conversation {
|
||||
export interface ContextMeta {
|
||||
context_note_id: number | null;
|
||||
context_note_title: string | null;
|
||||
auto_notes: { id: number; title: string; score?: number | null }[];
|
||||
auto_notes: { id: number; title: string; score?: number | null; auto_injected?: boolean }[];
|
||||
auto_injected_notes?: { id: number; title: string; score?: number | null }[];
|
||||
}
|
||||
|
||||
export interface OllamaStatus {
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface Note {
|
||||
body: string;
|
||||
tags: string[];
|
||||
parent_id: number | null;
|
||||
project_id: number | null;
|
||||
milestone_id: number | null;
|
||||
status: TaskStatus | null;
|
||||
priority: TaskPriority | null;
|
||||
due_date: string | null;
|
||||
|
||||
@@ -37,9 +37,15 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const includedNoteIds = ref<Set<number>>(new Set());
|
||||
const includedNotes = ref<{ id: number; title: string }[]>([]);
|
||||
|
||||
// Suggested notes — auto-found by search, not yet included
|
||||
// Suggested notes — auto-found by search, not yet included (score 0.45–0.59)
|
||||
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
|
||||
|
||||
// Auto-injected notes — injected automatically because score >= 0.60
|
||||
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
|
||||
|
||||
// Note IDs excluded from auto-injection on next message
|
||||
const excludedNoteIds = ref<number[]>([]);
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
const convId = computed(() => {
|
||||
@@ -98,6 +104,8 @@ watch(convId, async (newId) => {
|
||||
includedNoteIds.value = new Set();
|
||||
includedNotes.value = [];
|
||||
suggestedNotes.value = [];
|
||||
autoInjectedNotes.value = [];
|
||||
excludedNoteIds.value = [];
|
||||
store.lastContextMeta = null;
|
||||
if (newId) {
|
||||
// Skip re-fetch if this conversation is already loaded (avoids race
|
||||
@@ -118,14 +126,35 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.lastContextMeta?.auto_notes,
|
||||
(newNotes) => {
|
||||
if (!newNotes) return;
|
||||
() => store.lastContextMeta,
|
||||
(meta) => {
|
||||
if (!meta) return;
|
||||
|
||||
const alreadyIncluded = includedNoteIds.value;
|
||||
const alreadyAutoInjected = new Set(autoInjectedNotes.value.map((n) => n.id));
|
||||
const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id));
|
||||
for (const note of newNotes) {
|
||||
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id)) {
|
||||
suggestedNotes.value.push(note);
|
||||
|
||||
// Process auto_injected_notes (explicitly marked as injected)
|
||||
const injectedFromMeta = meta.auto_injected_notes ?? [];
|
||||
for (const note of injectedFromMeta) {
|
||||
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
||||
autoInjectedNotes.value.push(note);
|
||||
alreadyAutoInjected.add(note.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process auto_notes — auto_injected flag separates injected from suggested
|
||||
const autoNotes = meta.auto_notes ?? [];
|
||||
for (const note of autoNotes) {
|
||||
if (note.auto_injected) {
|
||||
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
||||
autoInjectedNotes.value.push(note);
|
||||
alreadyAutoInjected.add(note.id);
|
||||
}
|
||||
} else {
|
||||
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id) && !alreadyAutoInjected.has(note.id)) {
|
||||
suggestedNotes.value.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +211,8 @@ async function sendMessage() {
|
||||
undefined,
|
||||
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
|
||||
true, // enable thinking in the full chat view
|
||||
undefined,
|
||||
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
|
||||
);
|
||||
sending.value = false;
|
||||
|
||||
@@ -303,6 +334,14 @@ function includeNote(note: { id: number; title: string }) {
|
||||
includedNoteIds.value = new Set([...includedNoteIds.value, note.id]);
|
||||
includedNotes.value.push(note);
|
||||
suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id);
|
||||
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== note.id);
|
||||
}
|
||||
|
||||
function excludeAutoNote(noteId: number) {
|
||||
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== noteId);
|
||||
if (!excludedNoteIds.value.includes(noteId)) {
|
||||
excludedNoteIds.value = [...excludedNoteIds.value, noteId];
|
||||
}
|
||||
}
|
||||
|
||||
function removeIncludedNote(noteId: number) {
|
||||
@@ -461,23 +500,31 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Context sidebar -->
|
||||
<aside v-if="includedNotes.length || suggestedNotes.length" class="context-sidebar">
|
||||
<!-- IN CONTEXT section -->
|
||||
<template v-if="includedNotes.length">
|
||||
<div class="context-sidebar-header">In Context</div>
|
||||
|
||||
<!-- Explicitly included notes -->
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<aside v-if="autoInjectedNotes.length || includedNotes.length || suggestedNotes.length" class="context-sidebar">
|
||||
<!-- AUTO-INCLUDED section -->
|
||||
<template v-if="autoInjectedNotes.length">
|
||||
<div class="context-sidebar-header">Auto-included</div>
|
||||
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">
|
||||
{{ note.title }}
|
||||
</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">×</button>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{
|
||||
'score-high': note.score >= 0.75,
|
||||
'score-medium': note.score >= 0.60 && note.score < 0.75,
|
||||
'score-low': note.score < 0.60,
|
||||
}"
|
||||
:title="`Relevance score: ${note.score}`"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Exclude from auto-injection">×</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- SUGGESTED section -->
|
||||
<template v-if="suggestedNotes.length">
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': includedNotes.length }">Suggested</div>
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
|
||||
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">
|
||||
{{ note.title }}
|
||||
@@ -495,6 +542,18 @@ onUnmounted(() => {
|
||||
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- IN CONTEXT section -->
|
||||
<template v-if="includedNotes.length">
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
|
||||
<!-- Explicitly included notes -->
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">
|
||||
{{ note.title }}
|
||||
</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -814,6 +873,9 @@ onUnmounted(() => {
|
||||
.context-note-included {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.context-note-auto {
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
.context-note-suggested {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -19,6 +20,7 @@ const toast = useToastStore();
|
||||
const title = ref("");
|
||||
const body = ref("");
|
||||
const tags = ref<string[]>([]);
|
||||
const projectId = ref<number | null>(null);
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
@@ -136,12 +138,14 @@ function dismissTagSuggestions() {
|
||||
let savedTitle = "";
|
||||
let savedBody = "";
|
||||
let savedTags: string[] = [];
|
||||
let savedProjectId: number | null = null;
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
title.value !== savedTitle ||
|
||||
body.value !== savedBody ||
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags);
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
projectId.value !== savedProjectId;
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -156,9 +160,11 @@ onMounted(async () => {
|
||||
title.value = store.currentNote.title;
|
||||
body.value = store.currentNote.body;
|
||||
tags.value = [...(store.currentNote.tags || [])];
|
||||
projectId.value = store.currentNote.project_id ?? null;
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -172,10 +178,12 @@ async function save() {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Note saved");
|
||||
} else {
|
||||
@@ -183,6 +191,7 @@ async function save() {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
});
|
||||
dirty.value = false;
|
||||
toast.show("Note created");
|
||||
@@ -223,10 +232,11 @@ async function autoSave() {
|
||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value });
|
||||
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value, project_id: projectId.value } as Record<string, unknown>);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
@@ -290,6 +300,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
@input="markDirty"
|
||||
/>
|
||||
|
||||
<div class="field-row-meta">
|
||||
<div class="meta-field">
|
||||
<label class="meta-label">Project</label>
|
||||
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TagInput
|
||||
v-model="tags"
|
||||
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
||||
@@ -480,4 +497,25 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/editor-shared.css" />
|
||||
<style src="@/assets/editor-shared.css" />
|
||||
<style scoped>
|
||||
.field-row-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.meta-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
.meta-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,581 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
interface MilestoneSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
pct: number;
|
||||
total: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
color: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
summary?: {
|
||||
task_counts: { todo?: number; in_progress?: number; done?: number };
|
||||
note_count: number;
|
||||
milestone_summary: MilestoneSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
|
||||
const projects = ref<Project[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const activeTab = ref<"all" | "active" | "completed" | "archived">("all");
|
||||
|
||||
// New project modal
|
||||
const showNewProjectModal = ref(false);
|
||||
const newTitle = ref("");
|
||||
const newDescription = ref("");
|
||||
const newGoal = ref("");
|
||||
const creating = ref(false);
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (activeTab.value === "all") return projects.value;
|
||||
return projects.value.filter((p) => p.status === activeTab.value);
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await apiGet<{ projects: Project[] }>("/api/projects");
|
||||
projects.value = data.projects;
|
||||
// Fetch summaries (including milestone_summary) in parallel
|
||||
await Promise.allSettled(
|
||||
projects.value.map(async (p) => {
|
||||
try {
|
||||
const full = await apiGet<Project>(`/api/projects/${p.id}`);
|
||||
p.summary = full.summary;
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
error.value = "Failed to load projects.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function milestoneColor(index: number): string {
|
||||
const palette = [
|
||||
"var(--color-primary)",
|
||||
"var(--color-success)",
|
||||
"#c98a00",
|
||||
"#8b5cf6",
|
||||
"#ef4444",
|
||||
"#06b6d4",
|
||||
];
|
||||
return palette[index % palette.length];
|
||||
}
|
||||
|
||||
onMounted(loadProjects);
|
||||
|
||||
function openNewProjectModal() {
|
||||
newTitle.value = "";
|
||||
newDescription.value = "";
|
||||
newGoal.value = "";
|
||||
showNewProjectModal.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showNewProjectModal.value = false;
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!newTitle.value.trim()) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
const project = await apiPost<Project>("/api/projects", {
|
||||
title: newTitle.value.trim(),
|
||||
description: newDescription.value.trim() || undefined,
|
||||
goal: newGoal.value.trim() || undefined,
|
||||
});
|
||||
projects.value.unshift(project);
|
||||
showNewProjectModal.value = false;
|
||||
toast.show("Project created");
|
||||
router.push(`/projects/${project.id}`);
|
||||
} catch {
|
||||
toast.show("Failed to create project", "error");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: Project["status"]): string {
|
||||
if (status === "active") return "Active";
|
||||
if (status === "completed") return "Completed";
|
||||
if (status === "archived") return "Archived";
|
||||
return status;
|
||||
}
|
||||
|
||||
function truncate(text: string | null, max = 120): string {
|
||||
if (!text) return "";
|
||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="projects-list">
|
||||
<div class="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
|
||||
:key="tab"
|
||||
:class="['tab-btn', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="loading-msg">Loading...</p>
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
|
||||
<div v-else-if="filteredProjects.length === 0" class="empty-state">
|
||||
<p class="empty-title">No projects yet</p>
|
||||
<p class="empty-subtitle">Create your first project to organize tasks and notes.</p>
|
||||
<button class="btn-cta" @click="openNewProjectModal">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="projects-grid">
|
||||
<div
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
@click="router.push(`/projects/${project.id}`)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="project-title">{{ project.title }}</span>
|
||||
<span
|
||||
:class="['status-badge', `status-${project.status}`]"
|
||||
>{{ statusLabel(project.status) }}</span>
|
||||
</div>
|
||||
<p v-if="project.goal" class="project-goal">
|
||||
<span class="field-label">Goal:</span> {{ truncate(project.goal) }}
|
||||
</p>
|
||||
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
|
||||
|
||||
<!-- Milestone progress bars -->
|
||||
<div
|
||||
v-if="project.summary?.milestone_summary?.length"
|
||||
class="milestone-bars"
|
||||
>
|
||||
<div
|
||||
v-for="(ms, i) in project.summary.milestone_summary"
|
||||
:key="ms.id"
|
||||
class="milestone-bar-row"
|
||||
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
|
||||
>
|
||||
<span class="milestone-bar-label">{{ ms.title }}</span>
|
||||
<div class="milestone-bar-track">
|
||||
<div
|
||||
class="milestone-bar-fill"
|
||||
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="meta-date">Updated {{ new Date(project.updated_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Project Modal -->
|
||||
<teleport to="body">
|
||||
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-card">
|
||||
<h3 class="modal-title">New Project</h3>
|
||||
<div class="modal-field">
|
||||
<label>Title <span class="required">*</span></label>
|
||||
<input
|
||||
v-model="newTitle"
|
||||
type="text"
|
||||
class="modal-input"
|
||||
placeholder="Project title"
|
||||
autofocus
|
||||
@keydown.enter="createProject"
|
||||
@keydown.escape="closeModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Goal</label>
|
||||
<input
|
||||
v-model="newGoal"
|
||||
type="text"
|
||||
class="modal-input"
|
||||
placeholder="What are you trying to achieve?"
|
||||
@keydown.escape="closeModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
v-model="newDescription"
|
||||
class="modal-textarea"
|
||||
placeholder="Optional description..."
|
||||
rows="3"
|
||||
@keydown.escape="closeModal"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" @click="closeModal">Cancel</button>
|
||||
<button
|
||||
class="modal-btn modal-btn-primary"
|
||||
@click="createProject"
|
||||
:disabled="!newTitle.trim() || creating"
|
||||
>
|
||||
{{ creating ? "Creating..." : "Create" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.projects-list {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: inherit;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
.error-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.empty-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-cta {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-active {
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
.status-completed {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.status-archived {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.project-goal {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.project-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.milestone-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.milestone-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.milestone-bar-label {
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 0;
|
||||
flex: 0 0 30%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.milestone-bar-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--color-border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.milestone-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.milestone-bar-pct {
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
.meta-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.modal-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.modal-input,
|
||||
.modal-textarea {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.modal-input:focus,
|
||||
.modal-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.modal-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.modal-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.modal-card {
|
||||
margin: 1rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,12 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut } from "@/api/client";
|
||||
import { usePushStore } from "@/stores/push";
|
||||
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
const pushStore = usePushStore();
|
||||
const assistantName = ref("");
|
||||
const defaultModel = ref("");
|
||||
const installedModels = ref<string[]>([]);
|
||||
@@ -73,6 +75,7 @@ const searchError = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchSettings();
|
||||
pushStore.checkSubscription();
|
||||
assistantName.value = store.assistantName;
|
||||
newEmail.value = authStore.user?.email ?? "";
|
||||
|
||||
@@ -640,6 +643,59 @@ function hostname(url: string): string {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Push Notifications ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Push Notifications</h2>
|
||||
<p class="section-desc">
|
||||
Receive browser push notifications when a response is ready.
|
||||
</p>
|
||||
<template v-if="!pushStore.isSupported">
|
||||
<p class="push-unsupported">Push notifications are not supported in this browser.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="push-status-row">
|
||||
<span class="push-status-label">Permission:</span>
|
||||
<span
|
||||
:class="['push-permission-badge', {
|
||||
'perm-granted': pushStore.permission === 'granted',
|
||||
'perm-denied': pushStore.permission === 'denied',
|
||||
'perm-default': pushStore.permission === 'default',
|
||||
}]"
|
||||
>
|
||||
{{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="push-status-row">
|
||||
<span class="push-status-label">Status:</span>
|
||||
<span :class="['push-sub-badge', { 'sub-active': pushStore.isSubscribed }]">
|
||||
{{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="pushStore.error" class="push-error">{{ pushStore.error }}</p>
|
||||
<div class="actions" style="margin-top: 0.75rem;">
|
||||
<button
|
||||
v-if="!pushStore.isSubscribed"
|
||||
class="btn-save"
|
||||
@click="pushStore.subscribe()"
|
||||
:disabled="pushStore.loading || pushStore.permission === 'denied'"
|
||||
>
|
||||
{{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-secondary"
|
||||
@click="pushStore.unsubscribe()"
|
||||
:disabled="pushStore.loading"
|
||||
>
|
||||
{{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="pushStore.permission === 'denied'" class="field-hint" style="margin-top: 0.5rem;">
|
||||
Notifications are blocked. Allow them in your browser site settings to re-enable.
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Application URL (admin) ── full width ── -->
|
||||
<section v-if="authStore.isAdmin" class="settings-section full-width">
|
||||
<h2>Application URL</h2>
|
||||
@@ -1022,6 +1078,43 @@ function hostname(url: string): string {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* Push notifications */
|
||||
.push-unsupported {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.push-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.push-status-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.push-permission-badge,
|
||||
.push-sub-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||
.perm-denied { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
|
||||
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||
.push-error {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-danger, #e74c3c);
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
/* Responsive — collapse to 1 column on narrow screens */
|
||||
@media (max-width: 640px) {
|
||||
.settings-grid {
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useAssist } from "@/composables/useAssist";
|
||||
import { apiPost } from "@/api/client";
|
||||
import { apiPost, apiGet } from "@/api/client";
|
||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -25,6 +27,15 @@ const tags = ref<string[]>([]);
|
||||
const status = ref<TaskStatus>("todo");
|
||||
const priority = ref<TaskPriority>("none");
|
||||
const dueDate = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
const parentId = ref<number | null>(null);
|
||||
const parentTitle = ref("");
|
||||
const parentSearchQuery = ref("");
|
||||
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const parentSearchLoading = ref(false);
|
||||
const showParentDropdown = ref(false);
|
||||
let parentSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
@@ -144,6 +155,9 @@ let savedTags: string[] = [];
|
||||
let savedStatus: TaskStatus = "todo";
|
||||
let savedPriority: TaskPriority = "none";
|
||||
let savedDueDate = "";
|
||||
let savedProjectId: number | null = null;
|
||||
let savedMilestoneId: number | null = null;
|
||||
let savedParentId: number | null = null;
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
@@ -152,7 +166,10 @@ function markDirty() {
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
status.value !== savedStatus ||
|
||||
priority.value !== savedPriority ||
|
||||
dueDate.value !== savedDueDate;
|
||||
dueDate.value !== savedDueDate ||
|
||||
projectId.value !== savedProjectId ||
|
||||
milestoneId.value !== savedMilestoneId ||
|
||||
parentId.value !== savedParentId;
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -160,6 +177,58 @@ function onBodyUpdate(newVal: string) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function onParentSearchInput() {
|
||||
if (parentSearchTimer) clearTimeout(parentSearchTimer);
|
||||
if (!parentSearchQuery.value.trim()) {
|
||||
parentSearchResults.value = [];
|
||||
showParentDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
parentSearchTimer = setTimeout(async () => {
|
||||
const q = parentSearchQuery.value.trim();
|
||||
if (!q) return;
|
||||
parentSearchLoading.value = true;
|
||||
showParentDropdown.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ notes: Array<{ id: number; title: string }> }>(
|
||||
`/api/notes?q=${encodeURIComponent(q)}&type=task&limit=8`
|
||||
);
|
||||
parentSearchResults.value = data.notes.filter((t) =>
|
||||
!taskId.value || t.id !== taskId.value
|
||||
);
|
||||
} catch {
|
||||
parentSearchResults.value = [];
|
||||
} finally {
|
||||
parentSearchLoading.value = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectParentTask(task: { id: number; title: string }) {
|
||||
parentId.value = task.id;
|
||||
parentTitle.value = task.title;
|
||||
parentSearchQuery.value = task.title;
|
||||
showParentDropdown.value = false;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function onParentFocus() {
|
||||
if (parentSearchQuery.value) showParentDropdown.value = true;
|
||||
}
|
||||
|
||||
function hideParentDropdown() {
|
||||
setTimeout(() => { showParentDropdown.value = false; }, 200);
|
||||
}
|
||||
|
||||
function clearParentTask() {
|
||||
parentId.value = null;
|
||||
parentTitle.value = "";
|
||||
parentSearchQuery.value = "";
|
||||
parentSearchResults.value = [];
|
||||
showParentDropdown.value = false;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (taskId.value) {
|
||||
await store.fetchTask(taskId.value);
|
||||
@@ -170,12 +239,21 @@ onMounted(async () => {
|
||||
status.value = store.currentTask.status as TaskStatus;
|
||||
priority.value = store.currentTask.priority as TaskPriority;
|
||||
dueDate.value = store.currentTask.due_date || "";
|
||||
const taskRec = store.currentTask as Record<string, unknown>;
|
||||
projectId.value = (taskRec.project_id as number | null) ?? null;
|
||||
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
|
||||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||||
parentSearchQuery.value = parentTitle.value;
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -191,6 +269,9 @@ async function save() {
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
due_date: dueDate.value || null,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
};
|
||||
if (isEditing.value) {
|
||||
await store.updateTask(taskId.value!, data);
|
||||
@@ -200,6 +281,9 @@ async function save() {
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Task saved");
|
||||
} else {
|
||||
@@ -250,13 +334,19 @@ async function autoSave() {
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
due_date: dueDate.value || null,
|
||||
});
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
} as Record<string, unknown>);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
@@ -344,6 +434,49 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
@input="markDirty"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Project</label>
|
||||
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Milestone</label>
|
||||
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field parent-field">
|
||||
<label>Parent Task</label>
|
||||
<div class="parent-search-wrapper">
|
||||
<div class="parent-input-row">
|
||||
<input
|
||||
v-model="parentSearchQuery"
|
||||
type="text"
|
||||
class="field-input"
|
||||
placeholder="Search tasks..."
|
||||
@input="onParentSearchInput"
|
||||
@focus="onParentFocus"
|
||||
@blur="hideParentDropdown"
|
||||
/>
|
||||
<button
|
||||
v-if="parentId"
|
||||
class="btn-clear-parent"
|
||||
@click="clearParentTask"
|
||||
title="Clear parent task"
|
||||
>×</button>
|
||||
</div>
|
||||
<div v-if="showParentDropdown" class="parent-dropdown">
|
||||
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
|
||||
<div
|
||||
v-for="task in parentSearchResults"
|
||||
:key="task.id"
|
||||
class="parent-dropdown-item"
|
||||
@mousedown.prevent="selectParentTask(task)"
|
||||
>{{ task.title || "Untitled" }}</div>
|
||||
<div v-if="!parentSearchLoading && parentSearchResults.length === 0" class="parent-dropdown-item parent-empty">No tasks found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TagInput
|
||||
@@ -551,4 +684,49 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Parent task search */
|
||||
.parent-field { max-width: 360px; }
|
||||
.parent-search-wrapper { position: relative; }
|
||||
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.parent-input-row .field-input { flex: 1; }
|
||||
.btn-clear-parent {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
|
||||
.parent-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.parent-dropdown-item {
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
|
||||
.parent-empty {
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
.parent-empty:hover { background: none; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user