- .env.example: vider les secrets (à rotater côté Plesk)
- src/lib/auth.ts: fail-fast lazy si ADMIN_JWT_SECRET < 32 chars
- src/pages/api/upload.ts: retirer .svg (anti-XSS) + try/catch FS
- src/pages/api/content/[section].ts: masquer err.message (sauf ZodError)
- retirer gsap/three/@types/three/@react-three/{fiber,drei}/lucide-react
- bump astro 6.1.10 (GHSA-xr5h-phrj-8vxv)
- zod 4: z.email()/z.url() au lieu de .email()/.url()
- nettoyer vars inutilisées + Header script is:inline
Vitest (38 tests, 615ms):
- tests/lib/auth.test.ts: verifyPassword + verifySession (round-trip, secrets distincts)
- tests/lib/content.test.ts: load/save + comportement seed
- tests/api/files.test.ts: path traversal (../, /, imbriqué) + 200
- tests/api/upload.test.ts: extension/taille/sanitize folder + bucket
- tests/middleware.test.ts: gating admin pages vs APIs vs routes publiques
- astro check: 0 errors / 0 warnings / 0 hints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
5.2 KiB
TypeScript
140 lines
5.2 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
|
|
interface Props {
|
|
technical: string[];
|
|
soft: string[];
|
|
}
|
|
|
|
export default function PatchBay({ technical, soft }: Props) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
const svg = svgRef.current;
|
|
if (!container || !svg) return;
|
|
|
|
function drawCables() {
|
|
if (!svg || !container) return;
|
|
const rect = container.getBoundingClientRect();
|
|
const width = rect.width;
|
|
const height = rect.height;
|
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
svg.setAttribute('width', String(width));
|
|
svg.setAttribute('height', String(height));
|
|
|
|
// Find left + right jack positions
|
|
const leftJacks = container.querySelectorAll<HTMLElement>('[data-jack="left"]');
|
|
const rightJacks = container.querySelectorAll<HTMLElement>('[data-jack="right"]');
|
|
const cr = container.getBoundingClientRect();
|
|
|
|
const leftPoints = Array.from(leftJacks).map((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return { x: r.right - cr.left, y: r.top + r.height / 2 - cr.top };
|
|
});
|
|
const rightPoints = Array.from(rightJacks).map((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return { x: r.left - cr.left, y: r.top + r.height / 2 - cr.top };
|
|
});
|
|
|
|
// Build cable paths pairing left to right
|
|
const colors = ['var(--color-cyan)', 'var(--color-magenta)', 'var(--color-acid)'];
|
|
svg.innerHTML = '';
|
|
leftPoints.forEach((p1, i) => {
|
|
const pairIdx = i % rightPoints.length;
|
|
const p2 = rightPoints[pairIdx];
|
|
if (!p2) return;
|
|
const dx = p2.x - p1.x;
|
|
const sag = 20 + (i % 3) * 8;
|
|
const midY = (p1.y + p2.y) / 2 + sag;
|
|
const c1x = p1.x + dx * 0.4;
|
|
const c2x = p1.x + dx * 0.6;
|
|
const d = `M ${p1.x} ${p1.y} C ${c1x} ${midY}, ${c2x} ${midY}, ${p2.x} ${p2.y}`;
|
|
|
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
path.setAttribute('d', d);
|
|
path.setAttribute('fill', 'none');
|
|
path.setAttribute('stroke', colors[i % colors.length]);
|
|
path.setAttribute('stroke-width', '1.6');
|
|
path.setAttribute('stroke-opacity', '0.55');
|
|
path.style.filter = `drop-shadow(0 0 4px ${colors[i % colors.length]})`;
|
|
svg.appendChild(path);
|
|
|
|
// Plug tips
|
|
[p1, p2].forEach((p) => {
|
|
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
circle.setAttribute('cx', String(p.x));
|
|
circle.setAttribute('cy', String(p.y));
|
|
circle.setAttribute('r', '3');
|
|
circle.setAttribute('fill', colors[i % colors.length]);
|
|
circle.style.filter = `drop-shadow(0 0 6px ${colors[i % colors.length]})`;
|
|
svg.appendChild(circle);
|
|
});
|
|
});
|
|
}
|
|
|
|
drawCables();
|
|
const ro = new ResizeObserver(drawCables);
|
|
ro.observe(container);
|
|
window.addEventListener('resize', drawCables);
|
|
return () => {
|
|
ro.disconnect();
|
|
window.removeEventListener('resize', drawCables);
|
|
};
|
|
}, [technical.length, soft.length]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="relative grid grid-cols-1 sm:grid-cols-2 md:grid-cols-[1fr_auto_1fr] gap-6 sm:gap-10 md:gap-20 bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-sm p-5 sm:p-6 md:p-10"
|
|
>
|
|
{/* Left panel : technical */}
|
|
<div>
|
|
<h3 className="stencil text-sm uppercase tracking-[0.25em] text-[color:var(--color-cyan)] glow-text-cyan mb-4">
|
|
Techniques
|
|
</h3>
|
|
<ul className="space-y-2.5">
|
|
{technical.map((s) => (
|
|
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
|
<span className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
|
|
<span
|
|
data-jack="left"
|
|
className="w-3 h-3 rounded-full border-2 border-[color:var(--color-cyan)] bg-[color:var(--color-bg)]"
|
|
style={{ boxShadow: `inset 0 0 0 1px #000` }}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Middle : SVG cables */}
|
|
<div className="hidden md:block relative min-h-full">
|
|
<svg
|
|
ref={svgRef}
|
|
className="absolute inset-0 pointer-events-none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
/>
|
|
</div>
|
|
|
|
{/* Right panel : soft */}
|
|
<div>
|
|
<h3 className="stencil text-sm uppercase tracking-[0.25em] text-[color:var(--color-magenta)] glow-text-magenta mb-4">
|
|
Transverses
|
|
</h3>
|
|
<ul className="space-y-2.5">
|
|
{soft.map((s) => (
|
|
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
|
<span
|
|
data-jack="right"
|
|
className="w-3 h-3 rounded-full border-2 border-[color:var(--color-magenta)] bg-[color:var(--color-bg)]"
|
|
style={{ boxShadow: `inset 0 0 0 1px #000` }}
|
|
/>
|
|
<span className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|