Files
proof-of-work/proof-of-work/app/main/navigation.tsx
T
Keysat 2b0abad68e
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run
v1.2.0:6 — AI "generate today's workout" from a brain-dump
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.
2026-06-19 10:59:12 -05:00

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>
</>
);
}