init
This commit is contained in:
60
components/ai-analysis-card.tsx
Normal file
60
components/ai-analysis-card.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { generateAnalysisAction } from "@/app/ai/actions";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { AiAnalysis, AiAnalysisTargetType } from "@/lib/models/analysis";
|
||||
|
||||
type AiAnalysisCardProps = {
|
||||
targetType: AiAnalysisTargetType;
|
||||
targetId: string;
|
||||
analysis: AiAnalysis | null;
|
||||
};
|
||||
|
||||
export function AiAnalysisCard({ targetType, targetId, analysis }: AiAnalysisCardProps) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
() => generateAnalysisAction(targetType, targetId),
|
||||
null
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-1.5 text-lg font-semibold text-fg">
|
||||
<Sparkles size={18} className="text-accent" />
|
||||
Analiza AI
|
||||
</h2>
|
||||
<form action={formAction}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Generowanie..." : analysis ? "Wygeneruj ponownie" : "Generuj analizę"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
|
||||
|
||||
{analysis ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-fg/90">{analysis.summary}</p>
|
||||
{analysis.tips.length > 0 ? (
|
||||
<ul className="list-disc pl-5 text-sm text-fg/80">
|
||||
{analysis.tips.map((tip, index) => (
|
||||
<li key={index}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="text-xs text-fg/40">
|
||||
{formatDate(analysis.createdAt)} · {analysis.model}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg/60">Brak analizy. Wygeneruj podsumowanie i wskazówki AI.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
57
components/dashboard-analysis-card.tsx
Normal file
57
components/dashboard-analysis-card.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { generateDashboardAnalysisAction } from "@/app/ai/actions";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { AiAnalysis } from "@/lib/models/analysis";
|
||||
|
||||
type Props = {
|
||||
analysis: AiAnalysis | null;
|
||||
};
|
||||
|
||||
export function DashboardAnalysisCard({ analysis }: Props) {
|
||||
const [state, formAction, pending] = useActionState(generateDashboardAnalysisAction, null);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-1.5 text-lg font-semibold text-fg">
|
||||
<Sparkles size={18} className="text-accent" />
|
||||
Kondycja treningowa
|
||||
</h2>
|
||||
<form action={formAction}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Analizuję..." : analysis ? "Odśwież analizę" : "Generuj analizę"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
|
||||
|
||||
{analysis ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-fg/90">{analysis.summary}</p>
|
||||
{analysis.tips.length > 0 ? (
|
||||
<ul className="list-disc pl-5 text-sm text-fg/80">
|
||||
{analysis.tips.map((tip, index) => (
|
||||
<li key={index}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="text-xs text-fg/40">
|
||||
{formatDate(analysis.createdAt)} · {analysis.model}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg/60">
|
||||
Wygeneruj kompleksową analizę kondycji łączącą dane biegowe, siłowe, HRV i sen.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
components/empty-state.tsx
Normal file
27
components/empty-state.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type EmptyStateProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: { href: string; label: string };
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function EmptyState({ title, description, action, icon }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-muted/40 px-6 py-10 text-center">
|
||||
{icon ? <div className="text-fg/40">{icon}</div> : null}
|
||||
<div className="text-base font-semibold text-fg">{title}</div>
|
||||
{description ? <p className="max-w-sm text-sm text-fg/60">{description}</p> : null}
|
||||
{action ? (
|
||||
<Link
|
||||
href={action.href}
|
||||
className="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-fg transition-opacity hover:opacity-90"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
components/exercise-progress-chart.tsx
Normal file
64
components/exercise-progress-chart.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
type ExerciseProgressChartProps = {
|
||||
name: string;
|
||||
data: { label: string; volumeKg: number; topWeightKg?: number }[];
|
||||
};
|
||||
|
||||
export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps) {
|
||||
if (data.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<div className="mb-2 text-sm text-fg/60">{name}</div>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
|
||||
<XAxis dataKey="label" stroke="var(--color-fg)" opacity={0.5} fontSize={12} />
|
||||
<YAxis yAxisId="volume" stroke="var(--color-accent)" opacity={0.7} fontSize={12} width={48} />
|
||||
<YAxis
|
||||
yAxisId="weight"
|
||||
orientation="right"
|
||||
stroke="var(--color-sand)"
|
||||
opacity={0.7}
|
||||
fontSize={12}
|
||||
width={48}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-muted)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--color-fg)",
|
||||
}}
|
||||
formatter={(value, key) => [
|
||||
key === "topWeightKg" ? `${value} kg` : `${Math.round(Number(value)).toLocaleString("pl-PL")} kg`,
|
||||
key === "topWeightKg" ? "Maks. ciężar" : "Wolumen",
|
||||
]}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="volume"
|
||||
type="monotone"
|
||||
dataKey="volumeKg"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="weight"
|
||||
type="monotone"
|
||||
dataKey="topWeightKg"
|
||||
stroke="var(--color-sand)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
components/info-tooltip.tsx
Normal file
14
components/info-tooltip.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
export function InfoTooltip({ text }: { text: string }) {
|
||||
return (
|
||||
<span className="group relative inline-flex items-center">
|
||||
<Info size={13} className="cursor-default text-fg/40 transition-colors group-hover:text-fg/70" />
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 w-56 -translate-x-1/2 rounded-md border border-muted/40 bg-bg px-3 py-2 text-xs text-fg/70 opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
28
components/load-route-button.tsx
Normal file
28
components/load-route-button.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Map } from "lucide-react";
|
||||
import { loadActivityRoute, type LoadRouteState } from "@/app/running/actions";
|
||||
|
||||
export function LoadRouteButton({ activityId }: { activityId: string }) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
async (): Promise<LoadRouteState> => loadActivityRoute(activityId),
|
||||
null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<form action={formAction}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="flex items-center gap-1.5 rounded-md border border-muted/40 bg-surface px-3 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-accent/60 hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
<Map size={15} className={pending ? "animate-pulse" : ""} />
|
||||
{pending ? "Pobieranie mapy..." : "Załaduj mapę trasy"}
|
||||
</button>
|
||||
</form>
|
||||
{state && "error" in state ? <p className="text-sm text-accent">{state.error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
components/nav.tsx
Normal file
46
components/nav.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
import { Activity, Dumbbell, LayoutDashboard, Settings } from "lucide-react";
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Panel", icon: LayoutDashboard },
|
||||
{ href: "/running", label: "Bieganie", icon: Activity },
|
||||
{ href: "/strength", label: "Siłownia", icon: Dumbbell },
|
||||
{ href: "/settings", label: "Ustawienia", icon: Settings },
|
||||
];
|
||||
|
||||
export function Nav() {
|
||||
return (
|
||||
<header className="border-b border-muted/40 bg-surface">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-3 text-lg font-bold text-fg">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
width={72}
|
||||
height={72}
|
||||
className="-mt-1 -mb-6 h-[72px] w-[72px] rounded-2xl shadow-lg ring-2 ring-surface"
|
||||
/>
|
||||
<span className="hidden text-xs font-normal tracking-wide text-fg/50 sm:block">
|
||||
<span className="font-semibold text-accent">K</span>siążka{" "}
|
||||
<span className="font-semibold text-accent">N</span>otowań{" "}
|
||||
<span className="font-semibold text-accent">U</span>dźwigów i{" "}
|
||||
<span className="font-semibold text-accent">R</span>ezultatów
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1 sm:gap-2">
|
||||
{links.map(({ href, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-fg/80 transition-colors hover:bg-bg hover:text-accent sm:px-3"
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
13
components/route-map-section.tsx
Normal file
13
components/route-map-section.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import nextDynamic from "next/dynamic";
|
||||
import type { RoutePoint } from "@/lib/models/running";
|
||||
|
||||
const RouteMap = nextDynamic(() => import("@/components/route-map").then((m) => m.RouteMap), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-full w-full animate-pulse rounded-lg bg-surface" />,
|
||||
});
|
||||
|
||||
export function RouteMapSection({ points }: { points: RoutePoint[] }) {
|
||||
return <RouteMap points={points} />;
|
||||
}
|
||||
53
components/route-map.tsx
Normal file
53
components/route-map.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { MapContainer, Polyline, TileLayer, CircleMarker, useMap } from "react-leaflet";
|
||||
import type { RoutePoint } from "@/lib/models/running";
|
||||
|
||||
type FitBoundsProps = { points: RoutePoint[] };
|
||||
|
||||
function FitBounds({ points }: FitBoundsProps) {
|
||||
const map = useMap();
|
||||
map.fitBounds(points, { padding: [24, 24] });
|
||||
return null;
|
||||
}
|
||||
|
||||
type RouteMapProps = { points: RoutePoint[] };
|
||||
|
||||
export function RouteMap({ points }: RouteMapProps) {
|
||||
if (points.length === 0) return null;
|
||||
|
||||
const start = points[0];
|
||||
const end = points[points.length - 1];
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={start}
|
||||
zoom={13}
|
||||
style={{ height: "100%", width: "100%", borderRadius: "inherit", background: "#2b2d42" }}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
subdomains="abcd"
|
||||
maxZoom={19}
|
||||
/>
|
||||
<FitBounds points={points} />
|
||||
<Polyline
|
||||
positions={points}
|
||||
pathOptions={{ color: "#fb4617", weight: 3, opacity: 0.9 }}
|
||||
/>
|
||||
<CircleMarker
|
||||
center={start}
|
||||
radius={6}
|
||||
pathOptions={{ color: "#f7f3e9", fillColor: "#fb4617", fillOpacity: 1, weight: 2 }}
|
||||
/>
|
||||
<CircleMarker
|
||||
center={end}
|
||||
radius={6}
|
||||
pathOptions={{ color: "#f7f3e9", fillColor: "#2b2d42", fillOpacity: 1, weight: 2 }}
|
||||
/>
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
26
components/stat-card.tsx
Normal file
26
components/stat-card.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type StatCardProps = {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
export function StatCard({ label, value, hint, highlight }: StatCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
highlight
|
||||
? "rounded-lg border border-l-2 border-muted/30 border-l-accent/70 bg-surface p-4"
|
||||
: "rounded-lg border border-muted/40 bg-surface px-3 py-2.5"
|
||||
}
|
||||
>
|
||||
<div className={highlight ? "text-xs font-medium uppercase tracking-widest text-fg/50" : "text-xs text-fg/55"}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={highlight ? "mt-1.5 text-2xl font-bold text-fg" : "mt-0.5 text-base font-semibold text-fg"}>{value}</div>
|
||||
{hint ? <div className="mt-1 text-xs text-fg/50">{hint}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
components/sync-button.tsx
Normal file
58
components/sync-button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { submitGarminMfaCode, syncGarminActivities, type SyncGarminState } from "@/app/running/actions";
|
||||
|
||||
export function SyncButton() {
|
||||
const [state, formAction, pending] = useActionState(async () => syncGarminActivities(), null);
|
||||
const [mfaState, mfaAction, mfaPending] = useActionState(
|
||||
async (_prev: SyncGarminState, formData: FormData) => submitGarminMfaCode(String(formData.get("code") ?? "")),
|
||||
null
|
||||
);
|
||||
|
||||
const mfaRequired = (state && "mfaRequired" in state) || (mfaState && "mfaRequired" in mfaState);
|
||||
const activeState = mfaState ?? state;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<form action={formAction}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="flex items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={16} className={pending ? "animate-spin" : ""} />
|
||||
{pending ? "Synchronizowanie..." : "Synchronizuj z Garmin"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{mfaRequired ? (
|
||||
<form action={mfaAction} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="Kod z e-maila"
|
||||
required
|
||||
className="w-32 rounded-md border border-muted/40 bg-bg px-2 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mfaPending}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-sm font-semibold text-fg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{mfaPending ? "Weryfikacja..." : "Zatwierdź kod"}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{mfaRequired ? (
|
||||
<div className="text-sm text-fg/60">Garmin wysłał kod weryfikacyjny na e-mail. Wpisz go powyżej.</div>
|
||||
) : null}
|
||||
{activeState && "error" in activeState ? <div className="text-sm text-accent">{activeState.error}</div> : null}
|
||||
{activeState && "success" in activeState ? <div className="text-sm text-fg/60">{activeState.success}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
components/volume-chart.tsx
Normal file
33
components/volume-chart.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
type VolumeChartProps = {
|
||||
data: { label: string; volumeKg: number }[];
|
||||
};
|
||||
|
||||
export function VolumeChart({ data }: VolumeChartProps) {
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
|
||||
<div className="mb-2 text-sm text-fg/60">Wolumen treningowy (ciężar × powtórzenia)</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="label" stroke="var(--color-fg)" opacity={0.5} fontSize={12} />
|
||||
<YAxis stroke="var(--color-fg)" opacity={0.5} fontSize={12} width={48} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--color-bg)" }}
|
||||
contentStyle={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-muted)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--color-fg)",
|
||||
}}
|
||||
formatter={(value) => [`${Math.round(Number(value)).toLocaleString("pl-PL")} kg`, "Wolumen"]}
|
||||
/>
|
||||
<Bar dataKey="volumeKg" fill="var(--color-accent)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user