feat(questions): add PillNavigation and QuickInputBar

- Replace custom sidebar with shared PillNavigation component
- Add QuickInputBar for quick question creation and search
- Add Questions app to mana-apps configuration
- Add questions icon to app-icons.ts
- Register questions URL in APP_URLS (port 5111)
This commit is contained in:
Till-JS 2026-01-29 13:38:16 +01:00
parent 1733580d05
commit 3d15539117
3 changed files with 255 additions and 129 deletions

View file

@ -1,23 +1,44 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { authStore, collectionsStore, questionsStore } from '$lib/stores';
import { apiClient } from '$lib/api/client';
import {
MagnifyingGlass,
Plus,
FolderOpen,
Gear,
SignOut,
Moon,
Sun,
Question,
CaretRight,
} from '@manacore/shared-icons';
import { questionsApi } from '$lib/api/questions';
import { theme } from '$lib/stores/theme';
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
QuickInputItem,
CreatePreview,
} from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
let { children } = $props();
let sidebarOpen = $state(true);
// App switcher items
const appItems = getPillAppItems('questions');
// Mobile detection
let isMobile = $state(false);
function updateMobileState() {
if (browser) {
isMobile = window.innerWidth <= 640;
}
}
// Navigation mode state
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Theme state
let isDark = $derived(theme.current === 'dark');
// User email for nav
let userEmail = $derived(authStore.user?.email || 'Menu');
onMount(async () => {
if (!authStore.isAuthenticated) {
@ -31,6 +52,20 @@
// Load initial data
await collectionsStore.load();
await questionsStore.load();
// Initialize mobile state
updateMobileState();
// Restore nav mode from localStorage
const savedSidebar = localStorage.getItem('questions-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
}
const savedCollapsed = localStorage.getItem('questions-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
}
});
async function handleSignOut() {
@ -39,6 +74,93 @@
goto('/login');
}
function handleToggleTheme() {
theme.toggle();
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('questions-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('questions-nav-collapsed', String(collapsed));
}
}
// InputBar search - search questions
async function handleSearch(query: string): Promise<QuickInputItem[]> {
if (!query.trim()) return [];
try {
const response = await questionsApi.getAll({ search: query, limit: 10 });
return response.data.map((q) => ({
id: q.id,
title: q.title,
subtitle: q.status || 'pending',
}));
} catch {
return [];
}
}
function handleSelect(item: QuickInputItem) {
goto(`/question/${item.id}`);
}
// Quick-create handler - parse question input
function handleParseCreate(query: string): CreatePreview | null {
if (!query.trim() || query.length < 3) return null;
// Simple parsing: the entire query is the question title
return {
title: `Create: "${query}"`,
subtitle: 'New question',
};
}
async function handleCreate(query: string): Promise<void> {
if (!query.trim()) return;
const question = await questionsStore.create({
title: query,
collectionId: collectionsStore.selectedId || undefined,
});
if (question) {
goto(`/question/${question.id}`);
}
}
// Collection dropdown items
let collectionItems = $derived<PillDropdownItem[]>([
{
id: 'all',
label: 'All Questions',
icon: 'help-circle',
onClick: () => selectCollection(null),
active: !collectionsStore.selectedId,
},
...collectionsStore.collections.map((c) => ({
id: c.id,
label: c.name,
icon: 'folder',
onClick: () => selectCollection(c.id),
active: collectionsStore.selectedId === c.id,
})),
]);
let currentCollectionLabel = $derived(
collectionsStore.selectedId
? collectionsStore.collections.find((c) => c.id === collectionsStore.selectedId)?.name ||
'Collection'
: 'All Questions'
);
function selectCollection(id: string | null) {
collectionsStore.select(id);
if (id) {
@ -47,128 +169,111 @@
questionsStore.load();
}
}
// Navigation items
let navItems = $derived<PillNavItem[]>([
{ href: '/', label: 'Questions', icon: 'help-circle' },
{ href: '/collections', label: 'Collections', icon: 'folder' },
{ href: '/settings', label: 'Settings', icon: 'settings' },
]);
</script>
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside
class="flex w-64 flex-col border-r border-border bg-card transition-all duration-200"
class:w-64={sidebarOpen}
class:w-16={!sidebarOpen}
>
<!-- Header -->
<div class="flex h-16 items-center justify-between border-b border-border px-4">
{#if sidebarOpen}
<h1 class="text-xl font-bold text-primary">Questions</h1>
{/if}
<button
onclick={() => (sidebarOpen = !sidebarOpen)}
class="rounded-lg p-2 text-muted-foreground hover:bg-secondary"
>
<CaretRight class="h-5 w-5 transition-transform {sidebarOpen ? 'rotate-180' : ''}" />
</button>
</div>
<svelte:window onresize={updateMobileState} />
<!-- New Question Button -->
<div class="p-4">
<a
href="/new"
class="flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover"
>
<Plus class="h-5 w-5" />
{#if sidebarOpen}
<span>New Question</span>
{/if}
</a>
</div>
<div class="layout-container">
<!-- Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Questions"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition="bottom"
showThemeToggle={true}
showLogout={authStore.isAuthenticated}
onLogout={handleSignOut}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
/>
<!-- Navigation -->
<nav class="flex-1 space-y-1 px-2">
<button
onclick={() => selectCollection(null)}
class="collection-item flex w-full items-center gap-3 rounded-lg px-3 py-2 text-foreground"
class:active={!collectionsStore.selectedId}
>
<Question class="h-5 w-5" />
{#if sidebarOpen}
<span>All Questions</span>
<span class="ml-auto text-xs text-muted-foreground">{questionsStore.total}</span>
{/if}
</button>
{#if sidebarOpen}
<div class="my-4 px-3 text-xs font-semibold uppercase text-muted-foreground">
Collections
</div>
{/if}
{#each collectionsStore.collections as collection}
<button
onclick={() => selectCollection(collection.id)}
class="collection-item flex w-full items-center gap-3 rounded-lg px-3 py-2 text-foreground"
class:active={collectionsStore.selectedId === collection.id}
>
<FolderOpen class="h-5 w-5" style="color: {collection.color}" />
{#if sidebarOpen}
<span class="truncate">{collection.name}</span>
<span class="ml-auto text-xs text-muted-foreground"
>{collection.questionCount || 0}</span
>
{/if}
</button>
{/each}
{#if sidebarOpen}
<a
href="/collections"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<Plus class="h-5 w-5" />
<span>Manage Collections</span>
</a>
{/if}
</nav>
<!-- Footer -->
<div class="border-t border-border p-2">
<button
onclick={() => theme.toggle()}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
{#if theme.current === 'dark'}
<Sun class="h-5 w-5" />
{:else}
<Moon class="h-5 w-5" />
{/if}
{#if sidebarOpen}
<span>Toggle Theme</span>
{/if}
</button>
<a
href="/settings"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<Gear class="h-5 w-5" />
{#if sidebarOpen}
<span>Settings</span>
{/if}
</a>
<button
onclick={handleSignOut}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<SignOut class="h-5 w-5" />
{#if sidebarOpen}
<span>Sign Out</span>
{/if}
</button>
</div>
</aside>
<!-- Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
placeholder="New question or search..."
emptyText="No questions found"
searchingText="Searching..."
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Create"
appIcon="help-circle"
bottomOffset={isMobile ? '70px' : isSidebarMode ? '0px' : '70px'}
/>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
{@render children()}
<main class="main-content bg-background" class:sidebar-mode={isSidebarMode && !isCollapsed}>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<style>
.layout-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: calc(80px + env(safe-area-inset-bottom));
transition: all 300ms ease;
}
.main-content.sidebar-mode {
padding-left: 180px;
padding-bottom: 0;
}
.content-wrapper {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
@media (max-width: 640px) {
.main-content {
padding-bottom: calc(150px + env(safe-area-inset-bottom));
}
.content-wrapper {
padding: 0.75rem;
}
}
</style>

View file

@ -63,6 +63,9 @@ const mailSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="n
// Inventory icon (box/package with gradient)
const inventorySvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#inventoryGrad)"/><path d="M280 380L512 260L744 380V644L512 764L280 644V380Z" fill="white"/><path d="M512 500V764M280 380L512 500L744 380" stroke="#14b8a6" stroke-width="24" stroke-linejoin="round"/><path d="M396 320L628 440" stroke="#14b8a6" stroke-width="16" stroke-linecap="round"/><rect x="460" y="560" width="104" height="80" rx="8" fill="#14b8a6" fill-opacity="0.3"/><defs><linearGradient id="inventoryGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#14b8a6"/><stop offset="1" stop-color="#0d9488"/></linearGradient></defs></svg>`;
// Questions icon (question mark with magnifying glass)
const questionsSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#questionsGrad)"/><circle cx="480" cy="440" r="180" stroke="white" stroke-width="40"/><path d="M620 580L740 700" stroke="white" stroke-width="48" stroke-linecap="round"/><path d="M440 360C440 332 462 310 490 310C520 310 550 330 550 370C550 420 490 430 490 480" stroke="white" stroke-width="32" stroke-linecap="round"/><circle cx="490" cy="540" r="20" fill="white"/><defs><linearGradient id="questionsGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#8b5cf6"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs></svg>`;
/**
* App icons as data URLs
* Use these directly in <img src={APP_ICONS.memoro}> or CSS background-image
@ -86,6 +89,7 @@ export const APP_ICONS = {
todo: svgToDataUrl(todoSvg),
mail: svgToDataUrl(mailSvg),
inventory: svgToDataUrl(inventorySvg),
questions: svgToDataUrl(questionsSvg),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -292,6 +292,22 @@ export const MANA_APPS: ManaApp[] = [
comingSoon: false,
status: 'development',
},
{
id: 'questions',
name: 'Questions',
description: {
de: 'KI Recherche-Assistent',
en: 'AI Research Assistant',
},
longDescription: {
de: 'Sammle Fragen und erhalte umfassende Antworten durch KI-gestützte Web-Recherche.',
en: 'Collect questions and get comprehensive answers through AI-powered web research.',
},
icon: APP_ICONS.questions,
color: '#8b5cf6',
comingSoon: false,
status: 'development',
},
];
/**
@ -378,6 +394,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
todo: { dev: 'http://localhost:5189', prod: 'https://todo.manacore.app' },
mail: { dev: 'http://localhost:5186', prod: 'https://mail.manacore.app' },
inventory: { dev: 'http://localhost:5188', prod: 'https://inventory.manacore.app' },
questions: { dev: 'http://localhost:5111', prod: 'https://questions.manacore.app' },
};
/**