175 lines
6.6 KiB
TypeScript
175 lines
6.6 KiB
TypeScript
import { useState } from 'react';
|
||
import { AnimatePresence, motion } from 'framer-motion';
|
||
|
||
interface Experience {
|
||
id: string;
|
||
company: string;
|
||
logo?: string;
|
||
role: string;
|
||
period: string;
|
||
duration: string;
|
||
description: string;
|
||
missions: string[];
|
||
stack: string[];
|
||
type: string;
|
||
current?: boolean;
|
||
}
|
||
|
||
interface Props {
|
||
items: Experience[];
|
||
}
|
||
|
||
function WaveformBar({ level }: { level: number }) {
|
||
// ASCII-like progress bar with blocks
|
||
const total = 12;
|
||
const filled = Math.round(level * total);
|
||
return (
|
||
<span className="font-mono tracking-tighter" aria-hidden="true">
|
||
{Array.from({ length: total }).map((_, i) => (
|
||
<span
|
||
key={i}
|
||
style={{
|
||
opacity: i < filled ? 1 : 0.25,
|
||
color: i < filled ? 'var(--color-cyan)' : 'var(--color-text-dim)',
|
||
}}
|
||
>
|
||
█
|
||
</span>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default function Setlist({ items }: Props) {
|
||
const [open, setOpen] = useState<string | null>(items[0]?.id ?? null);
|
||
|
||
return (
|
||
<div className="divide-y divide-[color:var(--color-border)] border-y border-[color:var(--color-border)]">
|
||
{items.map((exp, i) => {
|
||
const isOpen = open === exp.id;
|
||
const bpm = exp.current ? 174 : 160 - i * 10;
|
||
const level = exp.current ? 1 : Math.max(0.2, 1 - i * 0.15);
|
||
return (
|
||
<article key={exp.id} className={exp.current ? 'bg-[rgba(198,255,0,0.03)]' : ''}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(isOpen ? null : exp.id)}
|
||
className="w-full flex items-center gap-3 md:gap-6 px-3 md:px-5 py-4 text-left group hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||
aria-expanded={isOpen}
|
||
>
|
||
{/* Track number */}
|
||
<span className="font-mono text-xs md:text-sm text-[color:var(--color-text-dim)] w-8 tabular-nums">
|
||
{String(i + 1).padStart(2, '0')}
|
||
</span>
|
||
|
||
{/* Status tag */}
|
||
<span
|
||
className="hidden sm:inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-widest w-28"
|
||
style={{
|
||
color: exp.current ? 'var(--color-red)' : 'var(--color-text-dim)',
|
||
}}
|
||
>
|
||
{exp.current ? (
|
||
<>
|
||
<span className="led led-red animate-led-red"></span>
|
||
[now playing]
|
||
</>
|
||
) : (
|
||
<span className="opacity-60">—</span>
|
||
)}
|
||
</span>
|
||
|
||
{/* Company + role */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-display text-sm md:text-base font-semibold truncate group-hover:text-[color:var(--color-cyan)] transition-colors">
|
||
{exp.company}
|
||
</div>
|
||
<div className="font-mono text-[10px] md:text-xs text-[color:var(--color-text-muted)] uppercase tracking-wider truncate">
|
||
{exp.role} · {exp.period}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Waveform progress */}
|
||
<div className="hidden md:block">
|
||
<WaveformBar level={level} />
|
||
</div>
|
||
|
||
{/* BPM / duration */}
|
||
<div className="hidden sm:flex flex-col items-end gap-0.5 font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)] w-24 shrink-0">
|
||
<span>[{bpm} bpm]</span>
|
||
<span>[{exp.duration}]</span>
|
||
</div>
|
||
|
||
{/* Chevron */}
|
||
<span
|
||
className={`font-mono text-[color:var(--color-cyan)] transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||
aria-hidden="true"
|
||
>
|
||
>
|
||
</span>
|
||
</button>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{isOpen && (
|
||
<motion.div
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||
className="overflow-hidden"
|
||
>
|
||
<div className="px-3 md:px-5 pb-6 pt-2 grid grid-cols-1 md:grid-cols-[200px_1fr] gap-6">
|
||
<div className="flex items-start gap-3">
|
||
{exp.logo && (
|
||
<div className="w-16 h-16 shrink-0 flex items-center justify-center bg-[color:var(--color-bg)] border border-[color:var(--color-border)] rounded-sm p-2">
|
||
<img
|
||
src={exp.logo}
|
||
alt={exp.company}
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)] space-y-1">
|
||
<div>[type: {exp.type}]</div>
|
||
<div>[duration: {exp.duration}]</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-sm text-[color:var(--color-text)] mb-4">{exp.description}</p>
|
||
|
||
{exp.missions.length > 0 && (
|
||
<ul className="space-y-1.5 mb-4">
|
||
{exp.missions.map((m, idx) => (
|
||
<li key={idx} className="flex gap-2 text-sm">
|
||
<span className="font-mono text-[color:var(--color-acid)] shrink-0">›</span>
|
||
<span className="text-[color:var(--color-text-muted)]">{m}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{exp.stack.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{exp.stack.map((tech) => (
|
||
<span
|
||
key={tech}
|
||
className="font-mono text-[10px] uppercase tracking-wider px-2 py-1 text-[color:var(--color-cyan)] border border-[color:var(--color-cyan)]/30 bg-[color:var(--color-cyan)]/5 rounded-sm"
|
||
>
|
||
{tech}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|