2b0abad68e
Add a single-session AI flow alongside program generation: describe a
workout in plain words and get a ready-to-log workout back — exercises
with suggested weights, target reps, and set counts grounded in the
user's recent history. The suggestion can be inline-edited or refined
by sending a follow-up instruction back to the model, then "Use this
workout" pre-fills the normal New Workout form (nothing persists until
the user saves through the regular path).
Why reuse, not fork: the existing program-generation spine (detached
background runner, SSE streaming, lenient-JSON preview, 5 providers,
history context, library name->id mapping) already does the hard parts.
A new AIGeneration.kind discriminant ("program" | "workout", default
"program" via boot-time guarded ALTER) selects the parser and keeps the
ephemeral workout rows out of the program-shaped AI history. Refine is a
fresh generation seeded with the prior suggestion (validated through the
same schema before it re-enters the prompt).
Hand-off is sessionStorage -> /main/workouts/new?from=ai -> AiWorkoutPrefill,
which expands each suggestion into N sets and maps effort by cardio-ness
(Gear for cardio, RPE for strength). EditWorkoutData.id is now optional so
the prefill CREATEs rather than PATCHing a nonexistent id. The AI suggests
each weight in that exercise's effective logging unit (the library JSON
carries a per-exercise unit) so the stored number and unit never diverge.
Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
202 lines
7.1 KiB
TypeScript
202 lines
7.1 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-workout', label: "Today's workout" },
|
|
{ href: '/main/ai/generate', label: 'Generate program' },
|
|
{ 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>
|
|
</>
|
|
);
|
|
}
|