revamp
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ActionType } from "@/lib/types";
|
||||
|
||||
interface BarsProps {
|
||||
values: number[];
|
||||
indices: number[];
|
||||
action: ActionType | undefined;
|
||||
sorted: number[];
|
||||
range?: [number, number];
|
||||
pivotIndex?: number;
|
||||
stepDurationMs: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const stateColor = {
|
||||
idle: "hsl(var(--state-idle))",
|
||||
compare: "hsl(var(--state-compare))",
|
||||
swap: "hsl(var(--state-swap))",
|
||||
sorted: "hsl(var(--state-sorted))",
|
||||
pivot: "hsl(var(--state-pivot))",
|
||||
} as const;
|
||||
|
||||
function colorFor(
|
||||
i: number,
|
||||
indices: number[],
|
||||
action: ActionType | undefined,
|
||||
sorted: Set<number>,
|
||||
range: [number, number] | undefined,
|
||||
pivotIndex: number | undefined
|
||||
): string {
|
||||
if (sorted.has(i)) return stateColor.sorted;
|
||||
if (pivotIndex === i) return stateColor.pivot;
|
||||
if (indices.includes(i)) {
|
||||
if (action === "swap" || action === "shift" || action === "merge-write" || action === "overwrite") return stateColor.swap;
|
||||
return stateColor.compare;
|
||||
}
|
||||
if (range && i >= range[0] && i <= range[1]) return "hsl(var(--state-range) / 0.55)";
|
||||
return stateColor.idle;
|
||||
}
|
||||
|
||||
export default function Bars({ values, indices, action, sorted, range, pivotIndex, stepDurationMs, height = 320 }: BarsProps) {
|
||||
const max = Math.max(...values, 1);
|
||||
const sortedSet = new Set(sorted);
|
||||
const n = values.length;
|
||||
const showLabels = n <= 36;
|
||||
const barGapPct = n > 0 ? Math.min(1.2, 40 / n) : 0.6;
|
||||
|
||||
const primary = indices[0];
|
||||
const secondary = indices[1];
|
||||
const pointerDuration = Math.max(0.08, Math.min(0.35, stepDurationMs / 1000));
|
||||
|
||||
return (
|
||||
<div className="relative w-full select-none" style={{ height: height + 40 }}>
|
||||
<div
|
||||
className="absolute bottom-10 left-0 right-0 flex items-end justify-center gap-[var(--gap)] px-2"
|
||||
style={{ height, ["--gap" as any]: `${barGapPct}%` }}
|
||||
>
|
||||
{values.map((value, i) => {
|
||||
const color = colorFor(i, indices, action, sortedSet, range, pivotIndex);
|
||||
const isFocused = indices.includes(i) || pivotIndex === i;
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="relative flex-1 rounded-t-[3px] min-w-[2px]"
|
||||
style={{ backgroundColor: color }}
|
||||
animate={{
|
||||
height: `${(value / max) * 100}%`,
|
||||
backgroundColor: color,
|
||||
boxShadow: isFocused ? `0 0 16px ${color}` : "0 0 0 rgba(0,0,0,0)",
|
||||
}}
|
||||
transition={{ duration: Math.max(0.08, Math.min(0.4, stepDurationMs / 1000)), ease: "easeInOut" }}
|
||||
>
|
||||
{showLabels && (
|
||||
<span
|
||||
className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] font-mono tabular-nums"
|
||||
style={{ color }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* sliding pointers: show where the algorithm's "attention" is right now */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-8 px-2">
|
||||
{n > 0 && primary !== undefined && (
|
||||
<motion.div
|
||||
className="absolute bottom-0 flex flex-col items-center"
|
||||
style={{ width: `${100 / n}%` }}
|
||||
animate={{ left: `${(primary / n) * 100}%` }}
|
||||
transition={{ duration: pointerDuration, ease: "easeInOut" }}
|
||||
>
|
||||
<div
|
||||
className="h-0 w-0 border-x-[6px] border-x-transparent border-b-[8px]"
|
||||
style={{ borderBottomColor: action === "swap" ? stateColor.swap : stateColor.compare }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{n > 0 && secondary !== undefined && secondary !== primary && (
|
||||
<motion.div
|
||||
className="absolute bottom-0 flex flex-col items-center"
|
||||
style={{ width: `${100 / n}%` }}
|
||||
animate={{ left: `${(secondary / n) * 100}%` }}
|
||||
transition={{ duration: pointerDuration, ease: "easeInOut" }}
|
||||
>
|
||||
<div
|
||||
className="h-0 w-0 border-x-[6px] border-x-transparent border-b-[8px]"
|
||||
style={{ borderBottomColor: action === "swap" ? stateColor.swap : stateColor.compare }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { complexityCurves, curvePoints } from "@/lib/complexity";
|
||||
import { ComplexityClass } from "@/lib/types";
|
||||
|
||||
interface GrowthChartProps {
|
||||
active: ComplexityClass;
|
||||
n: number;
|
||||
maxN: number;
|
||||
labels: Record<ComplexityClass, string>;
|
||||
}
|
||||
|
||||
const CURVES: { key: ComplexityClass; color: string }[] = [
|
||||
{ key: "n+k", color: "hsl(var(--state-sorted))" },
|
||||
{ key: "nlogn", color: "hsl(var(--primary))" },
|
||||
{ key: "n2-adaptive", color: "hsl(var(--state-pivot))" },
|
||||
{ key: "n2", color: "hsl(var(--state-swap))" },
|
||||
];
|
||||
|
||||
const W = 320;
|
||||
const H = 160;
|
||||
const PAD = 10;
|
||||
|
||||
function toPath(points: { n: number; v: number }[], maxN: number) {
|
||||
return points
|
||||
.map((p, i) => {
|
||||
const x = PAD + (p.n / maxN) * (W - 2 * PAD);
|
||||
const y = H - PAD - p.v * (H - 2 * PAD);
|
||||
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export default function GrowthChart({ active, n, maxN, labels }: GrowthChartProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto overflow-visible">
|
||||
{/* axes */}
|
||||
<line x1={PAD} y1={H - PAD} x2={W - PAD} y2={H - PAD} stroke="hsl(var(--border))" strokeWidth={1} />
|
||||
<line x1={PAD} y1={PAD} x2={PAD} y2={H - PAD} stroke="hsl(var(--border))" strokeWidth={1} />
|
||||
|
||||
{CURVES.map(({ key, color }) => {
|
||||
const pts = curvePoints(complexityCurves[key], maxN);
|
||||
const isActive = key === active;
|
||||
return (
|
||||
<motion.path
|
||||
key={key}
|
||||
d={toPath(pts, maxN)}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={isActive ? 2.75 : 1.25}
|
||||
strokeOpacity={isActive ? 1 : 0.28}
|
||||
strokeLinecap="round"
|
||||
initial={false}
|
||||
animate={{ strokeOpacity: isActive ? 1 : 0.28, strokeWidth: isActive ? 2.75 : 1.25 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* marker at current n on the active curve */}
|
||||
{(() => {
|
||||
const pts = curvePoints(complexityCurves[active], maxN, 200);
|
||||
const closest = pts.reduce((a, b) => (Math.abs(b.n - n) < Math.abs(a.n - n) ? b : a));
|
||||
const x = PAD + (closest.n / maxN) * (W - 2 * PAD);
|
||||
const y = H - PAD - closest.v * (H - 2 * PAD);
|
||||
const color = CURVES.find((c) => c.key === active)?.color ?? "hsl(var(--primary))";
|
||||
return (
|
||||
<motion.circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={4}
|
||||
fill={color}
|
||||
animate={{ cx: x, cy: y }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-xs font-mono">
|
||||
{CURVES.map(({ key, color }) => (
|
||||
<div key={key} className="flex items-center gap-1.5" style={{ opacity: key === active ? 1 : 0.45 }}>
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span style={{ color: key === active ? color : "hsl(var(--muted-foreground))" }}>{labels[key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface NarrationProps {
|
||||
text: string;
|
||||
stepIndex: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function Narration({ text, stepIndex, title }: NarrationProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5">
|
||||
<p className="text-xs uppercase tracking-widest text-muted-foreground mb-2">{title}</p>
|
||||
<div className="min-h-[3.5rem]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={stepIndex}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="text-lg leading-relaxed text-balance"
|
||||
>
|
||||
{text}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlgorithmKey, LanguageCode } from "@/lib/types";
|
||||
|
||||
const ALGORITHMS: { key: AlgorithmKey; label: string }[] = [
|
||||
{ key: "bubble", label: "Bubble Sort" },
|
||||
{ key: "insertion", label: "Insertion Sort" },
|
||||
{ key: "merge", label: "Merge Sort" },
|
||||
{ key: "heap", label: "Heap Sort" },
|
||||
{ key: "quick", label: "Quick Sort" },
|
||||
{ key: "radix", label: "Radix Sort" },
|
||||
{ key: "counting", label: "Counting Sort" },
|
||||
{ key: "bucket", label: "Bucket Sort" },
|
||||
{ key: "shell", label: "Shell Sort" },
|
||||
];
|
||||
|
||||
const LANGUAGES: LanguageCode[] = ["en", "fr", "it", "de", "es", "ja", "no", "sv", "ua", "ru"];
|
||||
|
||||
interface AlgorithmPickerProps {
|
||||
value: AlgorithmKey;
|
||||
onChange: (key: AlgorithmKey) => void;
|
||||
triggerLabel: string;
|
||||
}
|
||||
|
||||
export function AlgorithmPicker({ value, onChange, triggerLabel }: AlgorithmPickerProps) {
|
||||
const current = ALGORITHMS.find((a) => a.key === value)?.label ?? triggerLabel;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" className="gap-2 justify-between min-w-[180px]">
|
||||
{current}
|
||||
<ChevronDown className="h-4 w-4 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(v) => onChange(v as AlgorithmKey)}>
|
||||
{ALGORITHMS.map((a) => (
|
||||
<DropdownMenuRadioItem key={a.key} value={a.key}>
|
||||
{a.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface LanguagePickerProps {
|
||||
value: LanguageCode;
|
||||
onChange: (lang: LanguageCode) => void;
|
||||
}
|
||||
|
||||
export function LanguagePicker({ value, onChange }: LanguagePickerProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{value.toUpperCase()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(v) => onChange(v as LanguageCode)}>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<DropdownMenuRadioItem key={lang} value={lang}>
|
||||
{lang.toUpperCase()}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Play, Pause, Shuffle, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
|
||||
export type Speed = "slow" | "normal" | "fast" | "turbo";
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onShuffle: () => void;
|
||||
onReset: () => void;
|
||||
arraySize: number;
|
||||
onArraySizeChange: (n: number) => void;
|
||||
speed: Speed;
|
||||
onSpeedChange: (s: Speed) => void;
|
||||
stepIndex: number;
|
||||
stepCount: number;
|
||||
onScrub: (i: number) => void;
|
||||
labels: {
|
||||
play: string; pause: string; shuffle: string; reset: string;
|
||||
arraySize: string; elements: string; speed: string;
|
||||
speedSlow: string; speedNormal: string; speedFast: string; speedTurbo: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SPEEDS: Speed[] = ["slow", "normal", "fast", "turbo"];
|
||||
|
||||
export default function PlaybackControls({
|
||||
isPlaying, onTogglePlay, onShuffle, onReset,
|
||||
arraySize, onArraySizeChange, speed, onSpeedChange,
|
||||
stepIndex, stepCount, onScrub, labels, disabled,
|
||||
}: PlaybackControlsProps) {
|
||||
const speedLabel: Record<Speed, string> = {
|
||||
slow: labels.speedSlow, normal: labels.speedNormal, fast: labels.speedFast, turbo: labels.speedTurbo,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={onTogglePlay} size="lg" className="gap-2 min-w-[112px]">
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? labels.pause : labels.play}
|
||||
</Button>
|
||||
<Button onClick={onShuffle} variant="secondary" className="gap-2">
|
||||
<Shuffle className="h-4 w-4" />
|
||||
{labels.shuffle}
|
||||
</Button>
|
||||
<Button onClick={onReset} variant="outline" className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{labels.reset}
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto flex items-center rounded-lg border border-border overflow-hidden">
|
||||
{SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onSpeedChange(s)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
speed === s ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{speedLabel[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stepCount > 0 && (
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={stepCount}
|
||||
value={stepIndex}
|
||||
onChange={(e) => onScrub(Number(e.target.value))}
|
||||
className="w-full accent-primary h-1.5 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mb-2">
|
||||
<span>{labels.arraySize}</span>
|
||||
<span className="font-mono">{arraySize} {labels.elements}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[arraySize]}
|
||||
onValueChange={(v) => onArraySizeChange(v[0])}
|
||||
min={5}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { LanguagePicker } from "@/components/Pickers";
|
||||
import { LanguageCode } from "@/lib/types";
|
||||
|
||||
function GitHubIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .5C5.48.5.5 5.48.5 12c0 5.08 3.29 9.35 7.85 10.86.58.1.79-.24.79-.54 0-.27-.01-.99-.01-1.94-3.19.69-3.86-1.54-3.86-1.54-.53-1.36-1.29-1.72-1.29-1.72-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.2 1.77 1.2 1.03 1.77 2.69 1.26 3.34.96.1-.75.4-1.26.73-1.55-2.55-.29-5.23-1.28-5.23-5.72 0-1.26.45-2.29 1.2-3.1-.12-.29-.52-1.45.12-3.02 0 0 .97-.31 3.17 1.18.92-.26 1.91-.39 2.89-.39.98 0 1.97.13 2.89.39 2.2-1.49 3.17-1.18 3.17-1.18.64 1.57.24 2.73.12 3.02.75.81 1.2 1.84 1.2 3.1 0 4.46-2.69 5.43-5.25 5.71.41.35.78 1.03.78 2.08 0 1.5-.01 2.72-.01 3.08 0 .3.21.65.8.54C20.71 21.35 24 17.08 24 12c0-6.52-4.98-11.5-12-11.5z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GmailIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12.713l11.985-8.677a.868.868 0 0 0-.491-.148H.506c-.177 0-.344.055-.491.148L12 12.713zm0 1.431L.035 5.596A.875.875 0 0 0 0 6.125v11.75c0 .478.387.875.875.875h22.25c.478 0 .875-.387.875-.875V6.125a.875.875 0 0 0-.035-.529L12 14.144z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedInIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.761 0 5-2.239 5-5v-14c0-2.761-2.239-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.5c-.966 0-1.75-.784-1.75-1.75s.784-1.75 1.75-1.75 1.75.784 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.5h-3v-5.5c0-1.379-1.121-2.5-2.5-2.5s-2.5 1.121-2.5 2.5v5.5h-3v-11h3v1.474c.809-1.161 2.201-1.974 3.5-1.974 2.481 0 4.5 2.019 4.5 4.5v7z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface SiteFooterProps {
|
||||
year: number;
|
||||
rightsLabel: string;
|
||||
licenseLabel: string;
|
||||
viewLicenseLabel: string;
|
||||
lang: LanguageCode;
|
||||
onLangChange: (l: LanguageCode) => void;
|
||||
}
|
||||
|
||||
export default function SiteFooter({ year, rightsLabel, licenseLabel, viewLicenseLabel, lang, onLangChange }: SiteFooterProps) {
|
||||
return (
|
||||
<footer className="border-t border-border bg-card/40 py-6 mt-16">
|
||||
<div className="container mx-auto px-4 md:px-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">© {year} Quintavalle Pietro. {rightsLabel}</p>
|
||||
<nav className="flex items-center gap-4">
|
||||
<LanguagePicker value={lang} onChange={onLangChange} />
|
||||
<Dialog>
|
||||
<DialogTrigger className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{viewLicenseLabel}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{licenseLabel}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="font-semibold text-foreground">Copyright (c) 2024 Quintavalle Pietro</p>
|
||||
<p>
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
</p>
|
||||
<p>
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
</p>
|
||||
<p>
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<a href="https://github.com/Quinta0" target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<GitHubIcon className="w-5 h-5" />
|
||||
</a>
|
||||
<a href="mailto:0pietroquintavalle0@gmail.com" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<GmailIcon className="w-5 h-5" />
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/in/pietro-quintavalle-996b96267/" target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<LinkedInIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface StatItemProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
function StatItem({ label, value }: StatItemProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] uppercase tracking-widest text-muted-foreground">{label}</span>
|
||||
<motion.span
|
||||
key={String(value)}
|
||||
initial={{ opacity: 0.4 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="text-2xl font-mono tabular-nums text-primary"
|
||||
>
|
||||
{value}
|
||||
</motion.span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatsPanelProps {
|
||||
title: string;
|
||||
comparisons: number;
|
||||
writes: number;
|
||||
stepLabel: string;
|
||||
stepIndex: number;
|
||||
stepCount: number;
|
||||
sortedLabel: string;
|
||||
sortedCount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default function StatsPanel({
|
||||
title, comparisons, writes, stepLabel, stepIndex, stepCount, sortedLabel, sortedCount, total,
|
||||
}: StatsPanelProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5">
|
||||
<p className="text-xs uppercase tracking-widest text-muted-foreground mb-3">{title}</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatItem label="Comparisons" value={comparisons} />
|
||||
<StatItem label="Writes" value={writes} />
|
||||
<StatItem label={stepLabel} value={`${stepIndex} / ${stepCount}`} />
|
||||
<StatItem label={sortedLabel} value={`${sortedCount} / ${total}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user