Commit 11d7a44d by PLN (Algolia)

feat(parvagues): add Montreuil Algorave gig page

15-track collapsible list driven by metadata extracted from the .tidal
sources via the Pulsar HUD parser. Each row: name, BPM, style, expandable
ingredients (per-orbit sample/role/CC bindings) + git.nech.pl src link.
Six tracks have IG story loops (5s, 16:9 cropped from 9:16 originals,
~150 KB each) with a fixed-position hover loupe (480×270) that escapes
the row's clipping box; click opens the highlight album. Page-level SC
embed + Instagram album link in MediaSection. Full-length story MP4s
gitignored — only the loop previews + jpg thumbs ship.
parent 5b5a5dfa
...@@ -32,3 +32,7 @@ yarn-error.log* ...@@ -32,3 +32,7 @@ yarn-error.log*
# LLM exchanges # LLM exchanges
code2prompt.json code2prompt.json
.vercel .vercel
# IG highlight full-length MP4s — kept local for re-cropping; only loops + jpgs are shipped
public/images/parvagues/lives/ig-live-highlight/*.mp4
!public/images/parvagues/lives/ig-live-highlight/*_loop.mp4
---
title: "Montreuil Algorave"
date: "2026-05-22"
time: "21:00"
location: "Montreuil, France"
address: "Montreuil, FR"
description: "Algorave par @a_f_alfl aux Nouveaux Sauvages à Montreuil, avec @shipow aux visuels."
ctaURL: "https://soundcloud.com/parvagues/live-montreuil-algorave"
ctaText: "Écouter sur SoundCloud"
audio: "https://soundcloud.com/parvagues/live-montreuil-algorave"
instagram: "https://www.instagram.com/stories/highlights/18035324351004139/"
video: ""
archive: ""
tags: ["livecoding", "algorave", "technojazz", "montreuil"]
---
Algorave par [@a_f_alfl](https://instagram.com/a_f_alfl) aux Nouveaux Sauvages à Montreuil, avec [@shipow](https://instagram.com/shipow) aux visuels.
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Image from 'next/image'; import Image from 'next/image';
import { getAllLives, getLiveData, getLivesImages, getLiveTracks } from '@/lib/livesData'; import { getAllLives, getLiveData, getLivesImages, getLiveTracks } from '@/lib/livesData';
...@@ -13,6 +13,70 @@ import Prism from 'prismjs'; ...@@ -13,6 +13,70 @@ import Prism from 'prismjs';
import 'prismjs/components/prism-haskell'; import 'prismjs/components/prism-haskell';
import 'prismjs/themes/prism-tomorrow.css'; import 'prismjs/themes/prism-tomorrow.css';
const LOUPE_W = 480; // px, matches w-[480px]
const LOUPE_H = 270; // px, 16:9 aspect of 480w (loops cropped from 9:16 stories)
const LOUPE_MARGIN = 16;
function LoopThumb({ src, href }) {
const thumbRef = useRef(null);
const [pos, setPos] = useState(null);
const onEnter = () => {
if (!thumbRef.current) return;
const r = thumbRef.current.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// Center on thumb; clamp so the loupe always stays fully on-screen
const x = Math.max(
LOUPE_W / 2 + LOUPE_MARGIN,
Math.min(vw - LOUPE_W / 2 - LOUPE_MARGIN, r.left + r.width / 2)
);
const y = Math.max(
LOUPE_H / 2 + LOUPE_MARGIN,
Math.min(vh - LOUPE_H / 2 - LOUPE_MARGIN, r.top + r.height / 2)
);
setPos({ x, y });
};
const onLeave = () => setPos(null);
return (
<a
ref={thumbRef}
href={href || '#'}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
title="Voir sur Instagram"
className="flex-shrink-0"
>
<video
src={src}
autoPlay muted loop playsInline
className={`w-12 h-12 rounded-md object-cover border transition-colors ${pos ? 'border-[var(--neon-high)]/60' : 'border-white/10'}`}
/>
{pos && (
<div
className="fixed z-[60] pointer-events-none transition-opacity duration-150 hidden md:block"
style={{
left: pos.x,
top: pos.y,
width: LOUPE_W,
transform: 'translate(-50%, -50%)',
}}
>
<video
src={src}
autoPlay muted loop playsInline
className="w-full rounded-xl border-2 border-[var(--neon-high)]/50 shadow-2xl shadow-black/90"
/>
</div>
)}
</a>
);
}
function TrackIngredient({ ingredient }) { function TrackIngredient({ ingredient }) {
return ( return (
<div className="bg-black/30 rounded-lg p-3 border border-white/5"> <div className="bg-black/30 rounded-lg p-3 border border-white/5">
...@@ -61,6 +125,9 @@ function TracksSection({ tracks }) { ...@@ -61,6 +125,9 @@ function TracksSection({ tracks }) {
<details key={track.name} className="group bg-white/[0.02] border border-white/[0.06] rounded-xl overflow-hidden"> <details key={track.name} className="group bg-white/[0.02] border border-white/[0.06] rounded-xl overflow-hidden">
<summary className="flex items-center gap-4 px-5 py-4 cursor-pointer hover:bg-white/[0.03] transition-colors"> <summary className="flex items-center gap-4 px-5 py-4 cursor-pointer hover:bg-white/[0.03] transition-colors">
<span className="font-mono text-xs text-[var(--text-muted)] w-6">{String(i + 1).padStart(2, '0')}</span> <span className="font-mono text-xs text-[var(--text-muted)] w-6">{String(i + 1).padStart(2, '0')}</span>
{track.loop && (
<LoopThumb src={track.loop} href={tracks.instagramHighlight} />
)}
<span className="font-display font-semibold text-sm flex-grow">{track.name}</span> <span className="font-display font-semibold text-sm flex-grow">{track.name}</span>
{track.bpm && <span className="text-[11px] text-[var(--text-muted)] font-mono">{track.bpm} BPM</span>} {track.bpm && <span className="text-[11px] text-[var(--text-muted)] font-mono">{track.bpm} BPM</span>}
{track.style && <span className="text-[10px] tracking-wider uppercase text-[var(--text-muted)]">{track.style}</span>} {track.style && <span className="text-[10px] tracking-wider uppercase text-[var(--text-muted)]">{track.style}</span>}
...@@ -71,7 +138,15 @@ function TracksSection({ tracks }) { ...@@ -71,7 +138,15 @@ function TracksSection({ tracks }) {
))} ))}
{track.file && ( {track.file && (
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-2"> <p className="text-[10px] text-[var(--text-muted)] font-mono mt-2">
src: {track.file} src:{' '}
<a
href={`https://git.nech.pl/pln/Tidal/blob/master/${track.file}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--neon-high)] transition-colors underline decoration-dotted decoration-white/20 underline-offset-2"
>
{track.file}
</a>
</p> </p>
)} )}
</div> </div>
...@@ -82,11 +157,12 @@ function TracksSection({ tracks }) { ...@@ -82,11 +157,12 @@ function TracksSection({ tracks }) {
); );
} }
function MediaSection({ audio, video, archive }) { function MediaSection({ audio, video, archive, instagram }) {
const links = [ const links = [
audio && { url: audio, label: detectPlatform(audio) }, audio && { url: audio, label: detectPlatform(audio) },
video && { url: video, label: detectPlatform(video) }, video && { url: video, label: detectPlatform(video) },
archive && { url: archive, label: detectPlatform(archive) }, archive && { url: archive, label: detectPlatform(archive) },
instagram && { url: instagram, label: detectPlatform(instagram) },
].filter(Boolean); ].filter(Boolean);
if (links.length === 0) return null; if (links.length === 0) return null;
...@@ -140,6 +216,7 @@ function detectPlatform(url) { ...@@ -140,6 +216,7 @@ function detectPlatform(url) {
if (url.includes('youtube')) return 'YouTube'; if (url.includes('youtube')) return 'YouTube';
if (url.includes('archive.org')) return 'Archive.org'; if (url.includes('archive.org')) return 'Archive.org';
if (url.includes('spotify')) return 'Spotify'; if (url.includes('spotify')) return 'Spotify';
if (url.includes('instagram')) return 'Instagram';
return 'Écouter'; return 'Écouter';
} }
...@@ -167,7 +244,7 @@ export default function LiveEvent({ live, images, tracks, isPostEvent: initialPo ...@@ -167,7 +244,7 @@ export default function LiveEvent({ live, images, tracks, isPostEvent: initialPo
const fm = live.frontmatter; const fm = live.frontmatter;
const eventDate = new Date((fm.date || '') + 'T00:00:00'); const eventDate = new Date((fm.date || '') + 'T00:00:00');
const dateStr = isNaN(eventDate.getTime()) ? null : format(eventDate, 'dd MMMM yyyy', { locale: fr }); const dateStr = isNaN(eventDate.getTime()) ? null : format(eventDate, 'dd MMMM yyyy', { locale: fr });
const hasMedia = fm.audio || fm.video || fm.archive; const hasMedia = fm.audio || fm.video || fm.archive || fm.instagram;
const title = fm.title || live.slug; const title = fm.title || live.slug;
const location = fm.location || ''; const location = fm.location || '';
...@@ -222,6 +299,7 @@ export default function LiveEvent({ live, images, tracks, isPostEvent: initialPo ...@@ -222,6 +299,7 @@ export default function LiveEvent({ live, images, tracks, isPostEvent: initialPo
audio={fm.audio} audio={fm.audio}
video={fm.video} video={fm.video}
archive={fm.archive} archive={fm.archive}
instagram={fm.instagram}
/> />
)} )}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment