Fix project/milestone association and redesign AppHeader navigation
Backend: - routes/tasks.py: POST + PUT were silently dropping project_id, milestone_id, parent_id from request body — root cause of association not saving from the task editor - routes/tasks.py: GET /api/tasks/:id now includes parent_title in response (secondary lookup when parent_id is set) - routes/notes.py: add PATCH /api/notes/:id for partial updates (used by sub-task status toggle; PUT already existed but PATCH was missing) - routes/projects.py: GET /api/projects/:id/notes now fetches milestone IDs and passes them via milestone_ids so tasks assigned to a milestone (but lacking project_id) are included in the project view - services/notes.py: create_note() auto-sets project_id from milestone when milestone_id is provided and project_id is omitted; list_notes() gains milestone_ids param — when combined with project_id uses OR condition (project_id=X OR milestone_id IN (...)) Frontend: - NoteEditorView: add MilestoneSelector; milestone resets when project changes; all save paths (save/create/auto-save) include milestone_id - stores/notes.ts: add milestone_id to createNote + updateNote types - TaskEditorView: sub-tasks now inherit milestone_id from parent task - AppHeader: three-zone layout — brand left, Notes/Projects/Tasks/Chat centered (absolute positioning), right rail with status/theme/? and gear dropdown containing Settings/Users/Logs; mobile dropdown unchanged Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
@@ -12,7 +12,10 @@ const { toggleShortcuts } = useShortcuts();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
const gearOpen = ref(false);
|
||||
const gearMenuRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const statusShortLabel = computed(() => {
|
||||
if (chatStore.ollamaStatus === "unavailable") return "Offline";
|
||||
@@ -45,59 +48,116 @@ function toggleMobileMenu() {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||
}
|
||||
|
||||
function toggleGear() {
|
||||
gearOpen.value = !gearOpen.value;
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (gearMenuRef.value && !gearMenuRef.value.contains(e.target as Node)) {
|
||||
gearOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout();
|
||||
mobileMenuOpen.value = false;
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
// Close mobile menu on route change
|
||||
router.afterEach(() => {
|
||||
mobileMenuOpen.value = false;
|
||||
gearOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => document.addEventListener("click", handleClickOutside));
|
||||
onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<nav class="nav">
|
||||
<!-- Left: brand -->
|
||||
<router-link to="/" class="nav-brand">
|
||||
<AppLogo />
|
||||
Fabled Assistant
|
||||
</router-link>
|
||||
|
||||
<button class="hamburger" @click="toggleMobileMenu" aria-label="Toggle menu">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
|
||||
<div class="nav-links" :class="{ open: mobileMenuOpen }">
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<div class="nav-center">
|
||||
<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>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="nav-link">Logs</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Right: status + utilities + gear + user -->
|
||||
<div class="nav-right">
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
<button class="btn-shortcuts" @click="toggleShortcuts" title="Keyboard shortcuts (?)">
|
||||
?
|
||||
</button>
|
||||
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||
|
||||
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
|
||||
|
||||
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||
</button>
|
||||
|
||||
<!-- Gear dropdown -->
|
||||
<div class="gear-menu" ref="gearMenuRef">
|
||||
<button class="btn-icon btn-gear" @click.stop="toggleGear" :class="{ active: gearOpen }" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="gearOpen" class="gear-dropdown">
|
||||
<router-link to="/settings" class="gear-item">Settings</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/users" class="gear-item">Users</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="gear-item">Logs</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-info">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
<span v-if="authStore.isAdmin" class="admin-badge">admin</span>
|
||||
<button class="btn-logout" @click="handleLogout" title="Sign out">
|
||||
Sign out
|
||||
</button>
|
||||
<button class="btn-logout" @click="handleLogout" title="Sign out">Sign out</button>
|
||||
</div>
|
||||
|
||||
<!-- Hamburger (mobile only) -->
|
||||
<button class="hamburger" @click="toggleMobileMenu" aria-label="Toggle menu">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<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>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="nav-link">Logs</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<div class="mobile-actions">
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
<button class="btn-icon" @click="toggleShortcuts">?</button>
|
||||
<button class="btn-icon" @click="toggleTheme">{{ theme === "dark" ? "\u2600" : "\u263E" }}</button>
|
||||
</div>
|
||||
<div class="mobile-user">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
<span v-if="authStore.isAdmin" class="admin-badge">admin</span>
|
||||
<button class="btn-logout" @click="handleLogout">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -105,13 +165,17 @@ router.afterEach(() => {
|
||||
.app-header {
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
padding: 0.5rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Left */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -120,28 +184,27 @@ router.afterEach(() => {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hamburger {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
/* Center — absolutely positioned so it's truly centered regardless of side widths */
|
||||
.nav-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.hamburger-line {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--color-text);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.nav-links {
|
||||
|
||||
/* Right */
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
@@ -159,12 +222,14 @@ router.afterEach(() => {
|
||||
background: var(--color-bg-card);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-left: 0.5rem;
|
||||
cursor: default;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
@@ -177,60 +242,79 @@ router.afterEach(() => {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.status-green .status-dot {
|
||||
background: var(--color-success, #2ecc71);
|
||||
}
|
||||
.status-yellow .status-dot {
|
||||
background: var(--color-warning, #f59e0b);
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
.status-orange .status-dot {
|
||||
background: #f97316;
|
||||
}
|
||||
.status-red .status-dot {
|
||||
background: var(--color-danger, #e74c3c);
|
||||
}
|
||||
.status-gray .status-dot {
|
||||
background: var(--color-text-muted);
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); }
|
||||
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
|
||||
.status-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: var(--color-danger, #e74c3c); }
|
||||
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.btn-shortcuts {
|
||||
|
||||
/* Icon buttons (?, theme, gear) */
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.55rem;
|
||||
padding: 0.25rem 0.45rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-shortcuts:hover {
|
||||
.btn-icon:hover,
|
||||
.btn-icon.active {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
|
||||
/* Gear dropdown */
|
||||
.gear-menu {
|
||||
position: relative;
|
||||
}
|
||||
.gear-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow, rgba(0,0,0,0.15));
|
||||
min-width: 140px;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: var(--color-bg-card);
|
||||
.gear-item {
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.gear-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.gear-item.router-link-active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* User info */
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: 0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.username {
|
||||
font-size: 0.85rem;
|
||||
@@ -255,54 +339,81 @@ router.afterEach(() => {
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-logout:hover {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Hamburger — mobile only */
|
||||
.hamburger {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.hamburger-line {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--color-text);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Mobile menu */
|
||||
.mobile-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.mobile-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.mobile-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.mobile-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-center {
|
||||
display: none;
|
||||
}
|
||||
.status-indicator,
|
||||
.btn-icon,
|
||||
.user-info {
|
||||
display: none;
|
||||
}
|
||||
.hamburger {
|
||||
display: flex;
|
||||
}
|
||||
.nav-links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-direction: column;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.25rem;
|
||||
z-index: 100;
|
||||
}
|
||||
.nav-links.open {
|
||||
display: flex;
|
||||
}
|
||||
.nav-link {
|
||||
.mobile-menu .nav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.status-indicator {
|
||||
margin-left: 0;
|
||||
}
|
||||
.user-info {
|
||||
margin-left: 0;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
width: 100%;
|
||||
}
|
||||
.theme-toggle,
|
||||
.btn-logout {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.mobile-user .btn-logout {
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user