7a62690a4a
User-feedback-driven release after testing v1.1.0:3. Nine themes:
1. Multi-config persistence
- New AIConfigProfile table (per-user). Save N configs, toggle one
active. Switching providers no longer wipes the previous setup.
- UserPreferences gains activeAIConfigId; legacy single-config
columns are mirrored from the active profile so existing reads
keep working without conditional logic.
- Idempotent boot migration lifts any existing single-config row
into a default profile.
2. Ollama auto-detect
- The "Add config" form probes /api/tags on the StartOS internal
addresses (ollama.startos / ollama.embassy on :11434). If
reachable: URL pre-fills, model field becomes a dropdown of
installed models. Fixes the copy-paste UX.
3. Curated model dropdowns for major providers
- Claude: Opus 4.7, Sonnet 4.6 (1M ctx), Haiku 4.5
- OpenAI: GPT-5.5, 5.4, 5.4-mini, 5.4-nano
- Gemini: 3.1-pro-preview, 2.5-pro, 2.5-flash, etc.
- "Other (type your own)" stays for niche models.
- Fixes "I tried gemini-3.0-pro and got 404."
4. Background generation
- lib/ai/generationRunner.ts: detached runner with in-memory
pub/sub bus. POST /api/ai/generate kicks it off and returns
immediately. SSE stream attaches by id. The runner survives
request cancellation; navigating away no longer kills it.
- New AIGeneration columns: progressText (in-flight stream),
durationMs (final wall-clock).
- Generate UI shows a banner explaining background-safety.
- History detail page polls progress + renders partial JSON
live for cross-process resume (page refresh, new tab).
5. System prompt overhaul
- lib/ai/systemPromptBase.ts: structural contract prepended to
every template. Forces JSON-only output, library-exerciseId
usage (kills "exerciseId doesn't belong to this user" errors),
and per-resistance-exercise suggestedWeight (with-history vs
without-history variants).
- aiExerciseSchema + ProgramExercise gain suggestedWeight +
suggestedWeightUnit. Starting a workout from a ProgramDay
pre-populates SetLog.weight from the suggestion.
6. Test connection improvements
- Latency in seconds (was ms — confusing for slow Ollama).
- Stale "✓ Connected" clears on form change.
- Per-config Test (no need to activate first).
- Generous maxOutputTokens for thinking models.
- Gemini surfaces finishReason on empty response (e.g. "blocked
by safety filter") instead of generic "empty response."
- Test endpoint accepts a draft body so you can verify before
saving + before activating.
7. History detail view
- Click row → full program tree + exact prompts sent. Apply from
here without re-generating. Pending rows poll for progress.
8. Sidebar sub-navigation
- AI: Generate / History / Templates
- Settings: General / Password / Sessions / AI integration /
Export / Instance (admin) / Danger zone, with anchor scroll.
9. API key UX
- "Key saved" indicator on saved configs (was confusing to see
an empty input after a successful save).
Schema migrations (additive, idempotent in entrypoint):
- AIConfigProfile table created
- UserPreferences.activeAIConfigId
- AIGeneration.progressText + durationMs
- ProgramExercise.suggestedWeight + suggestedWeightUnit
Tests: 16 new (systemPromptBase, modelMenu, generationRunner). 177
total pass.
201 lines
7.0 KiB
TypeScript
201 lines
7.0 KiB
TypeScript
'use client';
|
|
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import {
|
|
LayoutDashboard,
|
|
Dumbbell,
|
|
ListChecks,
|
|
Calendar,
|
|
Sparkles,
|
|
Settings,
|
|
LogOut,
|
|
} from 'lucide-react';
|
|
import { logoutAction } from './actions';
|
|
|
|
interface NavigationProps {
|
|
userName: string;
|
|
isAdmin: boolean;
|
|
}
|
|
|
|
interface NavSubItem {
|
|
/** Either a route href or a section anchor (#…) on the parent page. */
|
|
href: string;
|
|
label: string;
|
|
/** Admin-only — hidden for non-admin users. */
|
|
adminOnly?: boolean;
|
|
}
|
|
|
|
interface NavLink {
|
|
href: string;
|
|
label: string;
|
|
icon: typeof LayoutDashboard;
|
|
/** v1.1.0:4 — sub-navigation rendered when the user is on this section.
|
|
* Items can either deep-link to a sibling route or scroll to an anchor
|
|
* on the parent page. */
|
|
subItems?: NavSubItem[];
|
|
}
|
|
|
|
const navLinks: NavLink[] = [
|
|
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
|
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
|
|
{ href: '/main/programs', label: 'Programs', icon: Calendar },
|
|
{
|
|
href: '/main/ai',
|
|
label: 'AI',
|
|
icon: Sparkles,
|
|
subItems: [
|
|
{ href: '/main/ai/generate', label: 'Generate' },
|
|
{ href: '/main/ai/history', label: 'History' },
|
|
{ href: '/main/ai/templates', label: 'Templates' },
|
|
],
|
|
},
|
|
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
|
|
{
|
|
href: '/main/settings',
|
|
label: 'Settings',
|
|
icon: Settings,
|
|
subItems: [
|
|
{ href: '/main/settings#general', label: 'General' },
|
|
{ href: '/main/settings#password', label: 'Password' },
|
|
{ href: '/main/settings#sessions', label: 'Sessions' },
|
|
{ href: '/main/settings#ai', label: 'AI integration' },
|
|
{ href: '/main/settings#data', label: 'Export & import' },
|
|
{ href: '/main/settings#instance', label: 'Instance', adminOnly: true },
|
|
{ href: '/main/settings#danger', label: 'Danger zone' },
|
|
],
|
|
},
|
|
];
|
|
|
|
export default function Navigation({ userName, isAdmin }: NavigationProps) {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
|
|
// A top-level item is "active" if the current pathname matches it
|
|
// exactly OR is a subpage. We use this to decide whether to expand
|
|
// the sub-nav under it.
|
|
const isActive = (href: string) =>
|
|
pathname === href || pathname.startsWith(href + '/');
|
|
|
|
// A sub-item's active state depends on what it points to:
|
|
// - Route subitem (no #): exact pathname match
|
|
// - Anchor subitem (has #): always inactive in nav (anchor change
|
|
// doesn't fire pathname). The browser handles the highlight.
|
|
const isSubActive = (subHref: string) => {
|
|
const [path] = subHref.split('#');
|
|
if (subHref.includes('#')) return false;
|
|
return pathname === path;
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
await logoutAction();
|
|
router.push('/auth/login');
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Desktop Sidebar */}
|
|
<aside className="hidden md:flex fixed left-0 top-0 h-screen w-[var(--sidebar-width)] border-r border-zinc-800 bg-[#0A0A0A] flex-col">
|
|
<div className="p-6 border-b border-zinc-800">
|
|
<h2 className="text-3xl font-display text-white tracking-wider">Proof of Work</h2>
|
|
</div>
|
|
|
|
<nav className="flex-1 overflow-y-auto p-4 space-y-1">
|
|
{navLinks.map((link) => {
|
|
const Icon = link.icon;
|
|
const active = isActive(link.href);
|
|
|
|
return (
|
|
<div key={link.href}>
|
|
<a
|
|
href={link.href}
|
|
className={`flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 ${
|
|
active
|
|
? 'bg-white text-black font-semibold'
|
|
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
|
|
}`}
|
|
>
|
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
|
<span className="text-sm">{link.label}</span>
|
|
</a>
|
|
|
|
{/* Expand sub-nav when this section is active. */}
|
|
{active && link.subItems && link.subItems.length > 0 && (
|
|
<ul className="ml-4 mt-1 mb-2 border-l border-zinc-800 pl-3 space-y-0.5">
|
|
{link.subItems
|
|
.filter((s) => !s.adminOnly || isAdmin)
|
|
.map((sub) => {
|
|
const subActive = isSubActive(sub.href);
|
|
return (
|
|
<li key={sub.href}>
|
|
<a
|
|
href={sub.href}
|
|
className={`block px-3 py-1.5 rounded text-xs transition-colors ${
|
|
subActive
|
|
? 'text-white bg-zinc-800'
|
|
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
|
|
}`}
|
|
>
|
|
{sub.label}
|
|
</a>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div className="border-t border-zinc-800 p-4 space-y-4">
|
|
<div className="px-4 py-2">
|
|
<p className="text-xs text-zinc-600 uppercase tracking-widest">User</p>
|
|
<p className="font-semibold text-white truncate mt-1">{userName}</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleLogout}
|
|
className="flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 w-full justify-start text-red-500 hover:text-red-400 hover:bg-red-950/30"
|
|
>
|
|
<LogOut className="w-5 h-5 flex-shrink-0" />
|
|
<span className="text-sm">Logout</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Mobile Bottom Nav (no sub-nav — limited screen real estate) */}
|
|
<header className="flex md:hidden fixed bottom-0 left-0 right-0 border-t border-zinc-800 bg-[#0A0A0A]">
|
|
<nav className="flex items-center justify-around h-[var(--bottom-nav-height)] w-full">
|
|
{navLinks.map((link) => {
|
|
const Icon = link.icon;
|
|
const active = isActive(link.href);
|
|
|
|
return (
|
|
<a
|
|
key={link.href}
|
|
href={link.href}
|
|
className={`flex flex-col items-center justify-center flex-1 h-full gap-1 transition-colors duration-200 ${
|
|
active
|
|
? 'text-white bg-zinc-900'
|
|
: 'text-zinc-500 hover:text-white'
|
|
}`}
|
|
>
|
|
<Icon className="w-6 h-6" />
|
|
<span className="text-xs">{link.label}</span>
|
|
</a>
|
|
);
|
|
})}
|
|
|
|
<button
|
|
onClick={handleLogout}
|
|
className="flex flex-col items-center justify-center flex-1 h-full gap-1 text-red-500 hover:text-red-400 transition-colors duration-200"
|
|
>
|
|
<LogOut className="w-6 h-6" />
|
|
<span className="text-xs">Logout</span>
|
|
</button>
|
|
</nav>
|
|
</header>
|
|
</>
|
|
);
|
|
}
|