Unverified Commit 0591c3cf by Paul-Louis NECH Committed by GitHub

Merge pull request #6 from PLNech/parvagues-and-dunbar

Parvagues and dunbar
parents fd0f4c1c 1e64cacb
---
description: Keep Markdown content in next/content and static assets in next/public with predictable paths
globs:
alwaysApply: true
---
# Content location (Markdown/MDX)
- Store all site content in `next/content/**` (e.g., posts, poems, talks, hydras, lives).
- Do not scatter Markdown/MDX outside `next/content/**`.
- When adding new sections, create a folder under `next/content/SECTION_NAME`.
# Static assets (images, gifs, icons, files)
- Place all static assets under `next/public/**`.
- Reference assets with absolute paths (e.g., `/images/...`) rather than relative filesystem paths.
- Prefer `next/image` where applicable for image optimization; fall back to `<img>` when necessary.
# Hotlinking
- Avoid hotlinking for persistent assets (images/gifs used in content or UI). Copy them into `next/public/**`.
# Organization conventions
- Use `next/public/images/SECTION/...` for section-specific assets (e.g., `parvagues`, `hydras`, `lives`, `posts`).
- Keep large/rarely used files out of git history when possible (use external storage/CDN), but link them in content.
# Build/deploy stability
- Do not fetch remote static assets at build time for core UI; ensure assets are present in `next/public/**`.
- Keep filenames stable to avoid cache-busting issues unless intentionally versioning assets.
---
description: Enforce environment variable handling and secrets hygiene with Vercel
globs:
alwaysApply: true
---
# Environment & secrets policy
- Do not commit `.env*` files to the repo (e.g. `.env`, `.env.local`, `.env.production`, `.env.development`).
- Manage secrets in Vercel Environment Variables (Production / Preview).
- Never hardcode API keys, tokens, or secrets in code, content, or markdown.
# Local development (Yarn + Vercel)
- Link the project and pull envs locally from `next/`:
- `yarn preview:link` (or `vercel link --yes`)
- `yarn preview:env:pull` (or `vercel env pull .env.local`)
- Only read environment variables via `process.env.*` at runtime or in build as appropriate.
# Next.js env conventions
- Use `NEXT_PUBLIC_*` prefix only for variables that are safe to expose to the browser.
- Server-only secrets must NOT be prefixed with `NEXT_PUBLIC_` and should only be used on server-side code paths (e.g. getServerSideProps, API routes).
# Vercel environments
- Keep distinct values per environment (Production vs Preview) in Vercel settings.
- Validate Preview behavior via a Vercel Preview URL before promoting to Production.
---
description: Prevent mixing Next.js data-fetching strategies per page and enforce client-only for Dunbar
globs:
alwaysApply: true
---
# Next.js data fetching guardrails
- Do not export multiple data fetching methods from the same page:
- Never mix `getServerSideProps` with `getStaticProps`/`getStaticPaths` in a single file.
- Each page must choose exactly one strategy or none.
- Dunbar page policy (`next/pages/dunbar/**`):
- Client-only app. Do NOT export `getServerSideProps`, `getStaticProps`, or `getStaticPaths` from any file under `next/pages/dunbar/**`.
- All data is local (localStorage). Use client-side effects/hooks only.
- Keep Dunbar free of SSR/SSG to avoid hydration and strategy conflicts.
- Content pages policy (posts, poems, talks, hydra, parvagues):
- Static generation is allowed and encouraged on those sections where already used.
- If server-side rendering is introduced on any non-Dunbar page, ensure no SSG export is present in the same file.
# Debugging stale strategy errors
- If you see: “You can not use getStaticProps or getStaticPaths with getServerSideProps” after removing an export:
- Stop the dev server and clear the Next.js cache: remove `next/.next/`
- Restart the dev server to ensure no stale compiled artifacts remain.
# Rationale
- Dunbar is a local-first, privacy-first client app. SSR/SSG unintentionally introduced on that route causes strategy conflicts in Next.js.
- Limiting data fetching exports ensures predictable build/runtime behavior and avoids “stale” mismatches across HMR/webpack caches.
---
description:
description: Enforce @/ alias usage for imports in Next.js app
globs:
alwaysApply: true
---
# Use @/ syntax for imports in Next.js projects
- Always use `@/components/xxx` syntax, not the `../../components/xxx` syntax.
\ No newline at end of file
- Always use `@/components/...`, `@/lib/...`, etc. Avoid long `../../` relative paths.
- Keep the webpack alias mapping `@ → next/` intact in `next/next.config.js`.
- When moving files, update imports to continue using the `@/` alias.
---
description: Enforce Next.js app root, directory layout, and command locations
globs:
alwaysApply: true
---
# App root and command execution
- The Next.js app root is `next/`. Do not move the app to the repository root or create additional app roots.
- Run all app commands from `next/` (or use `--cwd next` if invoking from repo root):
- `yarn dev`, `yarn build`, `yarn start`
- `vercel`, `vercel --prod`, `vercel build`
# Directory layout conventions
- Pages and routes live in `next/pages/**`.
- Reusable UI in `next/components/**`.
- Utility code in `next/lib/**`.
- Global and module styles in `next/styles/**` (only import global CSS from `pages/_app.js`).
- Static assets in `next/public/**` referenced with absolute paths like `/images/...`.
- Markdown/MDX content in `next/content/**` (do not scatter content elsewhere).
# Path alias
- Keep webpack alias `@` → `next/` in `next/next.config.js`.
- Prefer imports like `@/components/Button` over deep relative paths.
# Build/deploy stability
- Do not introduce separate Next.js app roots or change the root in Vercel settings (must be `next/`).
- Avoid build-time network fetches for core UI assets; ensure they live in `next/public/**`.
---
description:
globs: *.css
alwaysApply: false
description: Enforce Next.js global CSS rule — only import global CSS from pages/_app.js
globs:
alwaysApply: true
---
# AVoid global css:
Global CSS cannot be imported from files other than your Custom <App>. Due to the Global nature of stylesheets, and to avoid conflicts, Please move all first-party global CSS imports to pages/_app.js. Or convert the import to Component-Level CSS (CSS Modules).
# Avoid global CSS outside Custom App
- Global CSS must only be imported from `next/pages/_app.js`.
- Do not import global styles from other pages or components.
- Prefer CSS Modules or component-scoped CSS for local styles.
# Rationale
- Next.js enforces a single global stylesheet entrypoint to avoid style conflicts and runtime errors.
- This keeps styles isolated and reduces regressions for new devs.
# Allowed imports in _app.js (example)
- `import 'bootstrap/dist/css/bootstrap.css'`
- `import '@/styles/globals.css'`
- `import '@/styles/main.css'`
- `import '@/styles/masonry.css'`
---
description: Enforce Yarn (classic) as the only package manager and prevent lockfile drift
globs:
alwaysApply: true
---
# Yarn-only policy
- Use Yarn (classic) exclusively for installs and scripts.
- Keep `yarn.lock` as the single source of truth.
# Disallow other lockfiles
- Do not add or commit `package-lock.json`, `pnpm-lock.yaml`, or any other lockfile.
- If such files appear (e.g., in `next/` or repo root), remove them to avoid engine/lockfile drift.
# Deterministic installs
- Install with: `yarn install --frozen-lockfile`
- In CI and Vercel, rely on Yarn. Do not invoke npm or pnpm.
# Scripts
- Run all app scripts from `next/` (or use `--cwd next`), e.g.:
- `yarn dev`
- `yarn build`
- `yarn start`
- `yarn deploy:preview` / `yarn deploy:prod` (if defined)
---
description: Project-wide standards to keep Next.js app stable, Yarn-only, and Vercel deploys safe
globs:
alwaysApply: true
---
# Node & runtime
- Use Node 20 LTS (recommended) or at minimum >= 18.18 to satisfy Next.js 15.
- Align .nvmrc with package.json "engines" (prefer `20.x`). Do not downgrade Node.
- In Vercel, set Node 20 runtime (Project Settings > Build & Development Settings).
# Package manager — Yarn only
- Use Yarn (classic) exclusively; keep `yarn.lock` as the single source of truth.
- Do not add or commit `package-lock.json`, `pnpm-lock.yaml`, or any other lockfile.
- Install deterministically with: `yarn install --frozen-lockfile`.
- In CI/Vercel, rely on Yarn workflows; do not invoke npm or pnpm.
# Next.js project structure
- The app root is fixed at `next/`. Run all app commands from `next/` (or use `--cwd next`).
- Do not move the app to repository root or create additional app roots.
- Keep the webpack alias mapping `@ → next/` intact in `next/next.config.js`.
# Imports alias
- Prefer `@/xxx` imports (e.g., `@/components/Button`) instead of deep `../../` relative paths.
- When moving files, update imports to keep using `@/`.
# Global CSS policy
- Only import global CSS from `next/pages/_app.js`.
- Convert all other styles to CSS Modules or component-scoped CSS.
- Do not add new global CSS imports in pages/components.
- Note: This complements the existing rule in `.cursor/rules/no-global-css.mdc`.
# Content and static assets
- Markdown/MDX content lives under `next/content/**`.
- Static assets belong in `next/public/**` and should be referenced with `/...` paths.
- Avoid hotlinking for persistent assets; store them in `next/public`.
# Environment & secrets
- Do not commit `.env*` files. Manage secrets in Vercel Environment Variables (Production / Preview).
- For local development, sync envs from Vercel: `vercel env pull .env.local` (via local CLI).
- Never hardcode secrets or tokens in code or content.
# Vercel deploy policy (Preview-first)
- Default to Preview deploys for every branch:
- From `next/`: `vercel` (or `yarn vercel` if CLI is a devDependency).
- Share the Preview URL in PRs for review.
- Promote to Production only after approval:
- From `main` branch: `vercel --prod` (or `yarn vercel --prod`).
- Ensure Vercel Project “Root Directory” is set to `next/`.
- Use `vercel build` locally to reproduce platform builds when debugging.
# Scripts & CI conventions (Yarn)
- Local dev: run from `next/` → `yarn dev`
- Build locally/CI: from `next/` → `yarn build`
- Deterministic install: `yarn install --frozen-lockfile`
- Optional (recommended) devDependencies/scripts in `next/package.json` to keep Yarn-only workflow:
- Add devDependency: `"vercel": "^39"` (or current)
- Scripts:
- `"preview:link": "vercel link --yes"`
- `"preview:env:pull": "vercel env pull .env.local"`
- `"deploy:preview": "vercel --yes"`
- `"deploy:prod": "vercel --prod --yes"`
- `"platform:build": "vercel build"`
- Then use: `yarn preview:env:pull`, `yarn deploy:preview`, etc.
- Do not introduce npm-based CI steps (`npm ci`, `npx`, etc.) in this repo.
# PR & release guardrails
- All PRs must include a working Vercel Preview URL for reviewers.
- Do not merge if Preview build fails or diverges from local due to engine/lockfile drift.
- Production deploys happen via `vercel --prod` after merge to `main` and review.
# Quick checklist for new devs
- `nvm use 20`
- `cd next && yarn install --frozen-lockfile`
- `yarn dev`
- (optional first time) `yarn preview:link` then `yarn preview:env:pull`
- `yarn deploy:preview` to share a link before `yarn deploy:prod`
---
description: Enforce Vercel preview-first deploy policy with Yarn-only workflow
globs:
alwaysApply: true
---
# Vercel deploy policy (Preview-first)
- Always create a Preview deployment before Production.
- Run all Vercel CLI commands from `next/` (or use `--cwd next` if invoking from repo root).
- Ensure Vercel Project “Root Directory” is set to `next/` in Vercel settings.
# Commands (Yarn-only)
- Preview (default): `vercel` (or `yarn vercel` if CLI is a devDependency)
- Promote to Production (after approval): `vercel --prod` (or `yarn vercel --prod`)
- Link project: `vercel link --yes`
- Sync envs locally: `vercel env pull .env.local`
- Reproduce platform build locally: `vercel build`
# Guardrails
- Do not run `--prod` from feature branches.
- Include the Preview URL in PR description for review.
- Do not merge if Preview build fails or diverges from local due to engine/lockfile drift.
# Node & runtime
- Use Node 20.x runtime in Vercel.
- Local devs use `nvm use 20` before running Yarn commands.
# Yarn-only install in CI/Vercel
- Use `yarn install --frozen-lockfile`
- Do not invoke npm or pnpm in this repo.
node_modules/
.vercel
# Env files (managed via Vercel, never commit)
.env
.env.local
.env.development
.env.production
.env.test
.env.*.local
# Yarn-only policy: ignore other lockfiles
package-lock.json
pnpm-lock.yaml
npm-shrinkwrap.json
# OS/editor noise
.DS_Store
......@@ -31,3 +31,4 @@ yarn-error.log*
# LLM exchanges
code2prompt.json
.vercel
This source diff could not be displayed because it is too large. You can view the blob instead.
// next/components/ImageGallery.js
import { useState } from 'react';
import { useState, useEffect } from 'react'; // Added useEffect
import Image from 'next/image';
import Masonry from 'react-masonry-css';
import styles from '@/styles/parvagues.module.css'; // Import css modules
export default function ImageGallery({ images, slug }) {
const [selectedImage, setSelectedImage] = useState(null);
// Close modal on Escape key press
useEffect(() => {
const handleEsc = (event) => {
if (event.key === 'Escape') {
setSelectedImage(null);
}
};
if (selectedImage) {
window.addEventListener('keydown', handleEsc);
}
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [selectedImage]);
const breakpointColumns = {
default: 3,
default: 4, // Changed to 4 for a denser grid
1024: 3,
768: 2,
480: 1
480: 1,
};
const isPng = (src) => typeof src === 'string' && src.toLowerCase().endsWith('.png');
return (
<>
<div className="mt-8">
<h3 className="text-xl font-semibold mb-4 text-purple-400">Galerie</h3>
<h3 className="text-2xl font-bold mb-6 text-purple-300 text-center">Galerie Photos</h3>
<Masonry
breakpointCols={breakpointColumns}
className="masonry-grid" // Ensure this class or its child provides relative positioning for 'fill'
columnClassName="masonry-grid_column"
className={styles.galleryGrid} // Use CSS module for masonry grid
columnClassName={styles.galleryGridColumn}
>
{images.map((imageSrc, i) => (
<div
key={i}
className="mb-4 cursor-pointer hover:opacity-75 transition-opacity"
className={`${styles.galleryCard} mb-4 cursor-pointer group`}
onClick={() => setSelectedImage(imageSrc)}
>
{/* Ensure this div is the relatively positioned parent for fill */}
<div className="relative aspect-square rounded-lg overflow-hidden"> {/* Tailwind's aspect-square utility */}
<div className={`relative aspect-square rounded-lg overflow-hidden shadow-lg group-hover:shadow-xl transition-shadow duration-300 ${isPng(imageSrc) ? styles.pngBackground : 'bg-gray-800'}`}>
<Image
src={imageSrc}
alt={`${slug} image ${i + 1}`}
fill
className="object-cover" // object-cover will fill the square, cropping if necessary
className={`object-cover group-hover:scale-105 transition-transform duration-300`}
/>
</div>
</div>
......@@ -41,28 +59,28 @@ export default function ImageGallery({ images, slug }) {
</Masonry>
</div>
{/* Lightbox */}
{/* Modal */}
{selectedImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center cursor-zoom-out"
className={styles.modalOverlay}
onClick={() => setSelectedImage(null)}
>
<div className="relative max-w-[90vw] max-h-[90vh]">
<div
className={`${styles.modalContent} ${isPng(selectedImage) ? styles.pngBackgroundModal : 'bg-gray-900'}`}
onClick={(e) => e.stopPropagation()} // Prevent click inside modal from closing it
>
<Image
src={selectedImage}
alt="Selected image"
width={1200} // These are for the lightbox, not the gallery thumbs
height={800} // These define the max dimensions and aspect ratio for the lightbox image
className="max-w-full max-h-full object-contain" // object-contain is good for lightbox
width={1600}
height={1200}
className="max-w-full max-h-full object-contain rounded-lg"
/>
<button
className="absolute top-4 right-4 text-white text-2xl hover:text-purple-400 transition-colors"
onClick={(e) => {
e.stopPropagation();
setSelectedImage(null);
}}
className={styles.modalCloseButton}
onClick={() => setSelectedImage(null)}
>
×
&times; {/* Using HTML entity for '×' for better rendering */}
</button>
</div>
</div>
......
......@@ -18,67 +18,71 @@ export default function ParVaguesFooter() {
const year = new Date().getFullYear();
return (
<footer className="bg-black border-t border-[#d900ff]/20 py-8 relative overflow-hidden">
<footer className="bg-black border-t border-[#d900ff]/20 py-8 relative"> {/* Removed overflow-hidden */}
<div className={styles.neonGradient}></div>
<div className="max-w-6xl mx-auto px-4 relative z-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-start"> {/* Changed to md:grid-cols-3 and items-start */}
{/* About Column */}
<div>
<div className="md:col-span-1"> {/* Explicit column span */}
<p className="text-gray-400 text-sm mb-4">
Livecoding de musique libre<br />
Performances algorithmiques en direct.
</p>
<p className="text-xs text-gray-500">
&copy; {year} ParVagues. Tous droits réservés.
</p>
</div>
{/* Social Column */}
<div className="flex flex-col items-center md:items-end">
<div className="flex justify-center w-full">
<div className="relative">
{/* Logo Column (New) */}
<div className="md:col-span-1 flex flex-col items-center justify-center"> {/* This centers the block below */}
<div className="relative mb-4 flex justify-center"> {/* This ensures the image within this block is centered */}
<Image
src="/images/parvagues/logo.png"
alt="ParVagues Logo"
width={240}
height={240}
className="mx-auto mb-4"
width={100} // Reduced logo size
height={100} // Reduced logo size
/>
</div>
</div>
<div className="flex flex-wrap gap-4 justify-center md:justify-end">
{/* Social Column */}
<div className="md:col-span-1 flex flex-col items-center md:items-end"> {/* Explicit column span */}
<p className="text-gray-400 text-sm mb-3 text-center md:text-right">Restons connectés :</p>
<div className="flex flex-wrap gap-3 justify-center md:justify-end"> {/* Reduced gap */}
{socialLinks.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="bg-gray-800 hover:bg-[#8900b3]/60 text-white p-3 rounded-full transition-colors shadow-md hover:shadow-[#d900ff]/40"
className="bg-gray-800 hover:bg-purple-700/70 text-white p-2.5 rounded-full transition-colors shadow-md hover:shadow-purple-500/40" // Slightly smaller padding
aria-label={link.label}
>
{link.icon}
{React.cloneElement(link.icon, { size: '1.1em' })} {/* Slightly smaller icons */}
</a>
))}
</div>
</div>
</div>
{/* Navigation Links - more compact */}
<div className="w-full bg-black py-4">
<div className="flex justify-center items-center space-x-8 text-xs tracking-widest uppercase flex-wrap">
<Link href="/parvagues#music" className="text-gray-300 hover:text-[#ff3d7b] transition-colors tracking-wider px-3">
MUSIQUE
{/* Navigation Links - more compact and centered */}
<div className="w-full mt-8 pt-6 border-t border-purple-500/10"> {/* Added top margin, padding and border */}
<div className="flex justify-center items-center space-x-6 text-xs tracking-wider uppercase flex-wrap gap-y-2"> {/* Reduced space-x, added gap-y */}
<Link href="/parvagues#music" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
Musique
</Link>
<Link href="/parvagues#performances" className="text-gray-300 hover:text-[#ff3d7b] transition-colors tracking-wider px-3">
PERFORMANCES
<Link href="/parvagues#performances" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
Performances
</Link>
<Link href="/parvagues#about" className="text-gray-300 hover:text-[#ff3d7b] transition-colors tracking-wider px-3">
À PROPOS
<Link href="/parvagues#about" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
À Propos
</Link>
<a
href="mailto:parvagues@nech.pl?subject=Booking Request"
className="text-gray-300 hover:text-[#ff3d7b] transition-colors tracking-wider px-3"
className="text-gray-400 hover:text-purple-400 transition-colors px-2"
>
RÉSERVER
Réserver
</a>
</div>
</div>
......
......@@ -27,30 +27,28 @@ function useScrolledPast(threshold = 100) {
export default function ParVaguesHeader({ eventName = null, title = null }) {
const router = useRouter();
const isHome = router.pathname === '/parvagues';
const showInHeader = useScrolledPast(300);
const showInHeader = useScrolledPast(300); // Threshold for showing title on scroll
const headerTitle = title || eventName || 'ParVagues';
return (
<header className="sticky top-0 left-0 w-full z-50 bg-black/80 backdrop-blur-md border-b border-[#d900ff]/20">
<header className={`sticky top-0 left-0 w-full z-50 bg-black/80 backdrop-blur-md border-b border-[#d900ff]/20 ${styles.headerContainer}`}>
<div className={`${styles.neonGradient} opacity-5 absolute inset-0`}></div>
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
{/* Logo and Title with Navigation */}
<div className="flex items-center justify-between w-full">
<Link href="/parvagues" className="flex items-center group">
<div className="h-10 w-10 relative flex-shrink-0">
<div className="max-w-full mx-auto px-4 sm:px-6 flex items-center justify-between h-16"> {/* Adjusted padding for responsiveness */}
{/* Logo and Title */}
<Link href="/parvagues" className="flex items-center group flex-shrink-0"> {/* Added flex-shrink-0 */}
<div className="h-10 w-10 relative"> {/* Simplified logo div */}
<Image
src="/images/parvagues/logo.png"
alt="ParVagues Logo"
width={48}
height={48}
width={40}
height={40}
className="object-contain transition-all duration-300 group-hover:filter group-hover:drop-shadow-[0_0_8px_rgba(217,0,255,0.7)]"
/>
</div>
<div className="overflow-hidden ml-2">
<div className="overflow-hidden ml-3">
<span
className={`text-white font-bold transition-all duration-500 ${
showInHeader || !isHome ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-8'
className={`text-white font-bold transition-all duration-500 whitespace-nowrap ${ /* Added whitespace-nowrap */
showInHeader || !isHome ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full'
}`}
style={{
textShadow: '0 0 5px rgba(217, 0, 255, 0.7), 0 0 10px rgba(217, 0, 255, 0.5)',
......@@ -62,31 +60,28 @@ export default function ParVaguesHeader({ eventName = null, title = null }) {
</div>
</Link>
{/* Navigation and CTA */}
<div className="flex items-center space-x-6">
{/* Navigation Links */}
<nav className="flex items-center space-x-6 text-sm tracking-wider">
<Link href="/parvagues#music" className="text-gray-300 hover:text-[#ff3d7b] transition-colors">
{/* Ensure this nav doesn't cause overflow issues on very small screens - links might need to wrap or hide */}
<nav className="flex-grow flex justify-center items-center space-x-4 md:space-x-6 text-sm tracking-wider mx-2 sm:mx-4"> {/* Added horizontal margin */}
<Link href="/parvagues#music" className={`${styles.navLink} text-gray-300 hover:text-[#ff3d7b] transition-colors px-2 py-1 sm:px-3`}> {/* Added padding for touch targets */}
Music
</Link>
<Link href="/parvagues#performances" className="text-gray-300 hover:text-[#ff3d7b] transition-colors">
<Link href="/parvagues#performances" className={`${styles.navLink} text-gray-300 hover:text-[#ff3d7b] transition-colors px-2 py-1 sm:px-3`}>
Performances
</Link>
<Link href="/parvagues#about" className="text-gray-300 hover:text-[#ff3d7b] transition-colors">
<Link href="/parvagues#about" className={`${styles.navLink} text-gray-300 hover:text-[#ff3d7b] transition-colors px-2 py-1 sm:px-3`}>
About
</Link>
</nav>
{/* CTA button */}
<a
href="mailto:parvagues@nech.pl?subject=Booking%20Request"
className={`${styles.outlineButton} py-2 px-4 text-sm flex items-center whitespace-nowrap`}
<Link
href="/book"
className={`${styles.outlineButton} ${styles.bookButton} py-2 px-3 sm:px-4 text-xs sm:text-sm flex items-center whitespace-nowrap flex-shrink-0`} /* Adjusted padding, font size, added flex-shrink-0 */
>
<FaEnvelope className="mr-2 flex-shrink-0" />
<FaEnvelope className="mr-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4" /> {/* Responsive icon size */}
<span>Book</span>
</a>
</div>
</div>
</Link>
</div>
</header>
);
......
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import { extractLocations } from '@/lib/dunbar-nlp';
export default function FriendsList({
friends,
selectedFriendId,
onSelect,
onAddFriend,
onRemoveFriend,
onRename, // (id, name) => void
onSaveScroll, // (scrollTop:number) => void
initialScroll = 0,
}) {
const [name, setName] = useState('');
const [filter, setFilter] = useState('');
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const scrollRef = useRef(null);
// Restore scroll position when mounting / when list changes (preserve UX)
useEffect(() => {
if (!scrollRef.current) return;
// Next tick to allow DOM layout to settle
const id = setTimeout(() => {
try {
scrollRef.current.scrollTop = initialScroll || 0;
} catch {}
}, 0);
return () => clearTimeout(id);
}, [friends, initialScroll]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return friends;
return friends.filter((f) => f.name.toLowerCase().includes(q));
}, [friends, filter]);
// Offline location mentions per friend (from notes + events)
const locByFriend = useMemo(() => {
const m = new Map();
for (const f of friends) {
let text = '';
text += ' ' + (f.notes || '');
for (const ev of f.events || []) {
text += ' ' + (ev.title || '') + ' ' + (ev.notes || '') + ' ' + (ev.location || '');
}
const locs = extractLocations(text).map((l) => l.name);
const uniq = Array.from(new Set(locs));
m.set(f.id, uniq);
}
return m;
}, [friends]);
const handleAdd = () => {
const n = name.trim();
if (!n) return;
onAddFriend?.(n);
setName('');
};
const onClickItem = (id) => {
// Save current scroll before navigating to detail
if (scrollRef.current) onSaveScroll?.(scrollRef.current.scrollTop);
onSelect?.(id);
};
// Inline rename helpers
const startEdit = (id, currentName) => {
setEditingId(id);
setEditName(currentName || '');
};
const commitEdit = () => {
if (!editingId) return;
const n = editName.trim();
if (n) onRename?.(editingId, n);
setEditingId(null);
setEditName('');
};
const cancelEdit = () => {
setEditingId(null);
setEditName('');
};
return (
<div>
<div className={styles.card} style={{ marginBottom: 12 }}>
<div className={styles.cardHeader}>
<span>Friends ({friends.length})</span>
<div className={styles.row}>
<input
className={styles.input}
placeholder="Filter..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className={styles.row}>
<input
className={styles.input}
placeholder="Add a friend by name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAdd();
}}
/>
<button className={styles.btn} onClick={handleAdd} disabled={!name.trim()}>
Add
</button>
</div>
</div>
<div className={styles.list}>
<div ref={scrollRef} className={styles.listScroll}>
{filtered.map((f) => {
const evCount = Array.isArray(f.events) ? f.events.length : 0;
const connCount = f.relationships ? f.relationships.size : 0;
const isSel = f.id === selectedFriendId;
const isEditing = editingId === f.id;
return (
<div
key={f.id}
className={styles.listItem}
onClick={() => onClickItem(f.id)}
style={isSel ? { background: '#f5fbf7' } : undefined}
>
{isEditing ? (
<input
className={styles.input}
value={editName}
autoFocus
onClick={(e) => e.stopPropagation()}
onChange={(e) => setEditName(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
}}
style={{ maxWidth: 220 }}
/>
) : (
<div
className={styles.itemTitle}
title="Click to rename"
onClick={(e) => {
e.stopPropagation();
startEdit(f.id, f.name);
}}
style={{ cursor: 'text' }}
>
{f.name}
</div>
)}
<div className={styles.itemMeta}>
&nbsp;·&nbsp;{evCount} events · {connCount} connections
{(() => {
const locs = locByFriend.get(f.id) || [];
return locs.length ? <> · 📍 {locs.slice(0, 2).join(', ')}</> : null;
})()}
</div>
<div className={styles.itemRight} aria-hidden></div>
<button
className={styles.btnSecondary}
style={{ marginLeft: 8 }}
onClick={(e) => {
e.stopPropagation();
const ok = window.confirm(`Remove ${f.name}? This doesn’t delete events from others.`);
if (!ok) return;
onRemoveFriend?.(f.id);
}}
>
Remove
</button>
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: 12, color: '#666' }}>
{friends.length === 0
? 'No friends yet — add your first contact above.'
: 'No matches for your filter.'}
</div>
)}
</div>
</div>
</div>
);
}
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import Tooltip from '@/components/dunbar/Tooltip';
import {
distributeOnCircle,
colorByActivity,
firstWords,
isoDate,
} from '@/lib/dunbar';
function useSize(ref) {
const [size, setSize] = useState({ w: 800, h: 500 });
useEffect(() => {
if (!ref.current) return;
const el = ref.current;
const ro = new ResizeObserver(() => {
const r = el.getBoundingClientRect();
setSize({ w: Math.max(300, r.width), h: Math.max(300, r.height) });
});
ro.observe(el);
const r = el.getBoundingClientRect();
setSize({ w: Math.max(300, r.width), h: Math.max(300, r.height) });
return () => ro.disconnect();
}, [ref]);
return size;
}
function countRecentEvents(friend, days = 90) {
const now = Date.now();
const win = days * 24 * 60 * 60 * 1000;
let c = 0;
for (const e of friend.events || []) {
const t = new Date(e.date).getTime();
if (!isNaN(t) && now - t <= win) c += 1;
}
return c;
}
export default function OrbitsTab({ friends, buckets, openFriendDetail }) {
const wrapRef = useRef(null);
const { w, h } = useSize(wrapRef);
const cx = w / 2;
const cy = h / 2;
const rOuter = Math.min(w, h) * 0.45;
const rMiddle = Math.min(w, h) * 0.32;
const rInner = Math.min(w, h) * 0.18;
const friendMap = useMemo(() => {
const m = new Map();
for (const f of friends) m.set(f.id, f);
return m;
}, [friends]);
// Positions for each orbit
const posInner = useMemo(() => distributeOnCircle(buckets.inner || [], rInner, cx, cy, -Math.PI / 2), [buckets.inner, cx, cy, rInner]);
const posMiddle = useMemo(() => distributeOnCircle(buckets.middle || [], rMiddle, cx, cy, -Math.PI / 2), [buckets.middle, cx, cy, rMiddle]);
const posOuter = useMemo(() => distributeOnCircle(buckets.outer || [], rOuter, cx, cy, -Math.PI / 2), [buckets.outer, cx, cy, rOuter]);
const [tooltip, setTooltip] = useState({ x: 0, y: 0, show: false, html: null });
const handleEnter = (e, id) => {
const f = friendMap.get(id);
if (!f) return;
const totalEvents = (f.events || []).length;
const connectionCount = f.relationships ? f.relationships.size : 0;
const recent = [...(f.events || [])]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 3)
.map((ev) => `${isoDate(ev.date)}: ${firstWords(ev.notes, 3)}…`);
setTooltip({
x: e.clientX,
y: e.clientY,
show: true,
html: (
<div>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{f.name}</div>
<div>Total events: {totalEvents}</div>
<div>Connections: {connectionCount}</div>
{recent.length ? (
<div style={{ marginTop: 6 }}>
{recent.map((line, i) => (
<div key={i} style={{ color: '#555' }}>{line}</div>
))}
</div>
) : null}
</div>
),
});
};
const handleMove = (e) => {
setTooltip((t) => ({ ...t, x: e.clientX, y: e.clientY }));
};
const handleLeave = () => setTooltip((t) => ({ ...t, show: false }));
const renderNodes = (ids, posMap) => {
return ids.map((id) => {
const p = posMap.get(id);
const f = friendMap.get(id);
if (!p || !f) return null;
const c90 = countRecentEvents(f, 90);
const fill = colorByActivity(c90);
return (
<g key={id} transform={`translate(${p.x},${p.y})`} style={{ cursor: 'pointer' }}>
<circle
r={10}
fill={fill}
onMouseEnter={(e) => handleEnter(e, id)}
onMouseMove={handleMove}
onMouseLeave={handleLeave}
onClick={() => openFriendDetail?.(id)}
/>
<text className={styles.nodeLabel} textAnchor="middle" y={-14}>
{f.name}
</text>
</g>
);
});
};
return (
<div ref={wrapRef} className={styles.orbitsWrap}>
<svg width="100%" height="100%" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Orbits visualization">
{/* Orbits */}
<circle cx={cx} cy={cy} r={rOuter} fill="none" stroke="#e8efe9" />
<circle cx={cx} cy={cy} r={rMiddle} fill="none" stroke="#d7e7db" />
<circle cx={cx} cy={cy} r={rInner} fill="none" stroke="#c9e0cf" />
{/* Labels */}
<text x={cx} y={cy - rInner - 8} className={styles.orbitLabel} textAnchor="middle">Close</text>
<text x={cx} y={cy - rMiddle - 8} className={styles.orbitLabel} textAnchor="middle">Regular</text>
<text x={cx} y={cy - rOuter - 8} className={styles.orbitLabel} textAnchor="middle">Distant</text>
{/* Nodes */}
{renderNodes(buckets.inner || [], posInner)}
{renderNodes(buckets.middle || [], posMiddle)}
{renderNodes(buckets.outer || [], posOuter)}
</svg>
<Tooltip x={tooltip.x} y={tooltip.y} visible={tooltip.show}>
{tooltip.html}
</Tooltip>
</div>
);
}
import { useEffect, useMemo, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import {
buildSearchIndexes,
querySearch,
extractTagsFromText,
suggestTags,
suggestPersons,
} from '@/lib/dunbar-search';
import { tokenize } from '@/lib/dunbar-nlp';
export default function SearchTab({ friends, openFriend, openEvent }) {
const [q, setQ] = useState('');
const [includeTags, setIncludeTags] = useState(new Set());
const [excludeTags, setExcludeTags] = useState(new Set());
const [includePersons, setIncludePersons] = useState(new Set());
const [excludePersons, setExcludePersons] = useState(new Set());
const [indexes, setIndexes] = useState(null);
// Build indexes when friends change
useEffect(() => {
setIndexes(buildSearchIndexes(friends || []));
}, [friends]);
const onToggleSet = (set, value) => {
const s = new Set(set);
const v = String(value).trim();
if (!v) return set;
if (s.has(v)) s.delete(v);
else s.add(v);
return s;
};
const addIncTag = (t) => setIncludeTags((s) => onToggleSet(s, t));
const addExcTag = (t) => setExcludeTags((s) => onToggleSet(s, t));
const addIncPerson = (p) => setIncludePersons((s) => onToggleSet(s, p));
const addExcPerson = (p) => setExcludePersons((s) => onToggleSet(s, p));
const clearFacets = () => {
setIncludeTags(new Set());
setExcludeTags(new Set());
setIncludePersons(new Set());
setExcludePersons(new Set());
};
const results = useMemo(() => {
if (!indexes) return { friends: [], events: [] };
return querySearch(indexes, q, {
includeTags,
excludeTags,
includePersons,
excludePersons,
});
}, [indexes, q, includeTags, excludeTags, includePersons, excludePersons]);
// Render notes with inline #tags highlighted
const renderNotesWithTags = (text = '') => {
const re = /(#([\p{L}\p{N}_-]+))/gu;
const parts = [];
let lastIndex = 0;
let m;
while ((m = re.exec(text))) {
if (m.index > lastIndex) {
parts.push(<span key={`t-${lastIndex}`}>{text.slice(lastIndex, m.index)}</span>);
}
const full = m[1];
parts.push(
<span key={`tag-${m.index}`} className={styles.tagChip} style={{ marginRight: 6 }}>
{full}
</span>
);
lastIndex = m.index + full.length;
}
if (lastIndex < text.length) {
parts.push(<span key={`t-end`}>{text.slice(lastIndex)}</span>);
}
return <>{parts}</>;
};
// Query tokens for highlight (non-hashtag, length>=3)
const queryTokens = useMemo(
() =>
tokenize(q || '', { keepHashtags: true, removeDiacritics: true }).filter(
(t) => !t.startsWith('#') && t.length >= 3
),
[q]
);
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const renderHighlight = (text = '', tokens = []) => {
if (!tokens || tokens.length === 0) return text;
const pattern = new RegExp(`(${tokens.map(escapeRegExp).join('|')})`, 'gi');
const parts = String(text).split(pattern);
const tokenSet = new Set(tokens.map((t) => t.toLowerCase()));
return (
<>
{parts.map((part, i) =>
tokenSet.has(String(part).toLowerCase()) ? (
<mark key={i} style={{ backgroundColor: '#fff2a8', padding: '0 2px' }}>{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
);
};
const tagSuggestions = useMemo(() => (indexes ? suggestTags(indexes, q) : []), [indexes, q]);
const personSuggestions = useMemo(() => (indexes ? suggestPersons(indexes, q) : []), [indexes, q]);
return (
<div className={styles.twoCol} style={{ gap: 16 }}>
{/* Facets / Query */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Recherche</span>
<button className={styles.btnSecondary} onClick={clearFacets}>Reset filtres</button>
</div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
className={styles.input}
placeholder="Rechercher (texte, #tags, personnes)…"
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ minWidth: 260, flex: 1 }}
/>
</div>
{indexes && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>Suggestions #tags</div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
{tagSuggestions.map((t) => (
<button
key={t}
className={styles.btnSecondary}
onClick={() => addIncTag(t)}
title="Inclure ce tag"
>
#{t}
</button>
))}
</div>
<div style={{ fontSize: 12, color: '#666', margin: '8px 0 4px' }}>Suggestions personnes</div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
{personSuggestions.map((p) => (
<button
key={p}
className={styles.btnSecondary}
onClick={() => addIncPerson(p)}
title="Inclure cette personne"
>
{p}
</button>
))}
</div>
</div>
)}
{/* Active facets */}
<div style={{ marginTop: 8 }}>
<div className={styles.cardHeader}><span>Filtres actifs</span></div>
<div style={{ fontSize: 12, color: '#333', marginBottom: 4 }}>Inclure</div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
{Array.from(includeTags).map((t) => (
<button key={`inc-t-${t}`} className={styles.btnSecondary} onClick={() => addIncTag(t)}>#{t} </button>
))}
{Array.from(includePersons).map((p) => (
<button key={`inc-p-${p}`} className={styles.btnSecondary} onClick={() => addIncPerson(p)}>{p} </button>
))}
</div>
<div style={{ fontSize: 12, color: '#333', margin: '8px 0 4px' }}>Exclure</div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
{Array.from(excludeTags).map((t) => (
<button key={`exc-t-${t}`} className={styles.btnSecondary} onClick={() => addExcTag(t)}>#{t} </button>
))}
{Array.from(excludePersons).map((p) => (
<button key={`exc-p-${p}`} className={styles.btnSecondary} onClick={() => addExcPerson(p)}>{p} </button>
))}
</div>
</div>
</div>
{/* Results */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Résultats</span>
</div>
<div className={styles.twoCol} style={{ gap: 12 }}>
<div className={styles.card}>
<div className={styles.cardHeader}><span>Ami·es</span></div>
<div className={styles.listScroll} style={{ maxHeight: '50vh' }}>
{(results.friends || []).map((f) => (
<div key={f.id} className={styles.listItem} onClick={() => openFriend?.(f.refId)}>
<div className={styles.itemTitle}>{renderHighlight(f.name, queryTokens)}</div>
{(f.tags && f.tags.length) ? (
<div className={styles.tagRow}>
{f.tags.slice(0, 8).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null}
</div>
))}
{(!results.friends || results.friends.length === 0) && (
<div style={{ padding: 8, color: '#666' }}>Aucune correspondance.</div>
)}
</div>
</div>
<div className={styles.card}>
<div className={styles.cardHeader}><span>Événements</span></div>
<div className={styles.listScroll} style={{ maxHeight: '50vh' }}>
{(results.events || []).map((e) => (
<div key={e.id} className={styles.timelineEvent}>
<div className={styles.timelineDate}>{e.date}</div>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => openEvent?.(e)}
title="Ouvrir l’événement"
>
{renderHighlight(e.title || '(untitled)', queryTokens)}
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')}
</div>
{e.location ? <div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</div> : null}
<div className={styles.itemMeta}>Avec {(e.participantNames || []).join(', ')}</div>
{(e.tags && e.tags.length) ? (
<div className={styles.tagRow}>
{e.tags.slice(0, 10).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null}
</div>
))}
{(!results.events || results.events.length === 0) && (
<div style={{ padding: 8, color: '#666' }}>Aucune correspondance.</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
import React from 'react';
import styles from '@/styles/dunbar.module.css';
import { isoDate } from '@/lib/dunbar';
import { detectLang, topKeywordsForDocs, extractLocations } from '@/lib/dunbar-nlp';
export default function StatsTab({ stats, anniversaries = [], eventIndex = [], openFriend }) {
if (!stats) return null;
const items = [
{ label: 'Connections', value: stats.connections },
{ label: 'Active Friends (90d)', value: stats.activeFriends },
{ label: 'Total Events', value: stats.totalEvents },
{ label: 'Avg Events / Friend', value: stats.avgEventsPerFriend },
];
// Aggregate text insights (local-only): top keywords and locations across all events
const textInsights = React.useMemo(() => {
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''} ${e.location || ''}`,
}));
if (!docs.length) return { topTerms: [], topLocations: [] };
const corpusText = docs.map((d) => d.text).join(' ');
const lang = detectLang(corpusText) || null;
// Aggregate keywords by summing TF-IDF heads across docs
const perDoc = topKeywordsForDocs(docs, { lang, topK: 8 });
const agg = new Map();
for (const d of perDoc) {
for (const [term, score] of d.keywords) {
agg.set(term, (agg.get(term) || 0) + score);
}
}
const topTerms = Array.from(agg.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([term, score]) => ({ term, score }));
// Count location mentions (unique per event)
const locCounts = new Map();
for (const e of eventIndex || []) {
const locs = extractLocations(`${e.title || ''} ${e.notes || ''} ${e.location || ''}`).map((l) => l.name);
for (const name of new Set(locs)) {
locCounts.set(name, (locCounts.get(name) || 0) + 1);
}
}
const topLocations = Array.from(locCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([name, count]) => ({ name, count }));
return { topTerms, topLocations };
}, [eventIndex]);
// Group upcoming anniversaries by date (YYYY-MM-DD)
const grouped = anniversaries.reduce((acc, it) => {
const k = isoDate(it.date);
acc.set(k, [...(acc.get(k) || []), it]);
return acc;
}, new Map());
const annivDays = Array.from(grouped.entries()).sort(
(a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime()
);
return (
<div className={styles.card}>
<div className={styles.cardHeader}>Statistics</div>
<div className={styles.statsGrid}>
{items.map((it) => (
<div key={it.label} className={styles.statCard}>
<div className={styles.statLabel}>{it.label}</div>
<div className={styles.statValue}>{it.value}</div>
</div>
))}
</div>
{(textInsights.topTerms.length > 0 || textInsights.topLocations.length > 0) && (
<div style={{ marginTop: 16 }}>
<div className={styles.cardHeader}><span>Text Insights</span></div>
{textInsights.topTerms.length > 0 ? (
<div style={{ marginBottom: 8 }}>
<div className={styles.itemMeta} style={{ marginBottom: 4 }}>Top keywords</div>
<div className={styles.tagRow}>
{textInsights.topTerms.map(({ term }) => (
<span key={term} className={styles.tagChip}>{term}</span>
))}
</div>
</div>
) : null}
{textInsights.topLocations.length > 0 ? (
<div>
<div className={styles.itemMeta} style={{ marginBottom: 4 }}>Top locations</div>
<div className={styles.tagRow}>
{textInsights.topLocations.map(({ name, count }) => (
<span key={name} className={styles.tagChip}>📍 {name} × {count}</span>
))}
</div>
</div>
) : null}
</div>
)}
{annivDays.length > 0 && (
<div style={{ marginTop: 16 }}>
<div className={styles.cardHeader}>
<span>À venir (21 jours) Anniversaires</span>
</div>
<div className={styles.timeline}>
{annivDays.map(([day, items]) => (
<div key={day} className={styles.timelineGroup}>
<div className={styles.timelineDate}>{day}</div>
{items.map((it, idx) => (
<div key={day + '-' + idx} className={styles.timelineEvent}>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
title="Ouvrir la fiche ami·e"
onClick={() => openFriend?.(it.friendId)}
>
{it.friendName}
</div>
<div style={{ color: '#555' }}>{it.label}</div>
{/* Anchor event preview if provided */}
{it.anchorTitle || (it.anchorTags && it.anchorTags.length > 0) ? (
<div style={{ marginTop: 4 }}>
{it.anchorTitle ? (
<div style={{ color: '#333' }} title="Événement d’ancrage">
« {it.anchorTitle} »
</div>
) : null}
{Array.isArray(it.anchorTags) && it.anchorTags.length > 0 ? (
<div className={styles.tagRow}>
{it.anchorTags.slice(0, 8).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null}
</div>
) : null}
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
);
}
import React from 'react';
import styles from '@/styles/dunbar.module.css';
export default function Tooltip({ x, y, visible, children }) {
if (!visible) return null;
// Keep tooltip within viewport bounds with a small offset
const offset = 12;
const style = {
left: Math.max(8, x + offset),
top: Math.max(8, y + offset),
};
return (
<div className={styles.tooltip} style={style} role="tooltip">
{children}
</div>
);
}
......@@ -4,14 +4,30 @@ import styles from "./layout.module.css";
import utilStyles from "../styles/utils.module.css";
import Link from "next/link";
import Router from 'next/router'
import { useRouter } from 'next/router'
const name = "PLN";
export const siteTitle = "PLN's Works";
export const siteURL = "https://me.plnech.fr";
export const siteURL = "https://me.nech.pl";
export const twitterHandle = "@PaulLouisNech";
export const description = "PLN's Selected Works";
export default function Layout({ children, home }) {
const router = useRouter();
const path = router?.asPath || router?.pathname || '';
const isDunbar = path.startsWith('/dunbar');
// Simple feedback launcher: prompts for text then opens default mail client
const handleFeedbackMail = () => {
try {
const txt = typeof window !== 'undefined' ? window.prompt('Feedback for Dunbar (will open your email client):', '') : '';
const subject = 'Dunbar feedback';
const url = typeof window !== 'undefined' ? window.location.href : '';
const body = `${txt ? txt + '\\n\\n' : ''}From: ${url}`;
const mailto = `mailto:dunbar@nech.pl?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
if (typeof window !== 'undefined') window.location.href = mailto;
} catch {}
};
return (
<div className={styles.container}>
<Head>
......@@ -78,7 +94,7 @@ export default function Layout({ children, home }) {
</div>
)}
<footer>
PLN 2024 |
PLN 2025 |
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
......@@ -86,6 +102,20 @@ export default function Layout({ children, home }) {
>
</a>
{isDunbar && (
<>
{' '}|{' '}
<button
type="button"
onClick={handleFeedbackMail}
className={utilStyles.backButton}
style={{ cursor: 'pointer', border: 'none', background: 'transparent', padding: 0 }}
title="Send feedback about Dunbar"
>
Feedback (dunbar@nech.pl)
</button>
</>
)}
</footer>
</div>
);
......
.container {
max-width: 42rem;
max-width: 113rem;
padding: 0 1rem;
margin: 3rem auto 6rem;
}
......
import MiniSearch from 'minisearch';
import { fr as FR_LIST } from 'stopword';
import { FrenchStemmer } from 'snowball-stemmers';
// Utilities for FR-friendly tokenization and #tag extraction
const FR_STOP = new Set(FR_LIST || []);
let stemmer;
try {
// Prefer constructor form; some builds ship a class
stemmer = new FrenchStemmer();
} catch (e) {
// Fallback: library may export a plain object with stem(), or nothing usable
if (FrenchStemmer && typeof FrenchStemmer.stem === 'function') {
stemmer = FrenchStemmer;
} else {
stemmer = { stem: (w) => w };
}
}
export function extractTagsFromText(text = '') {
const tags = new Set();
const re = /#([\p{L}\p{N}_-]+)/gu;
let m;
while ((m = re.exec(text))) {
tags.add(m[1].toLowerCase());
}
return Array.from(tags);
}
function stripDiacritics(s) {
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
export function tokenizeFr(text = '') {
// Keep hashtags verbatim, otherwise split on non-letters/digits
const hashtagTokens = (text.match(/#[\p{L}\p{N}_-]+/gu) || []).map(t => t.toLowerCase());
const raw = stripDiacritics(text.toLowerCase()).replace(/#/g, ' ');
const parts = raw.split(/[^a-z0-9]+/g).filter(Boolean);
// remove stopwords and stem
const filtered = parts.filter(w => !FR_STOP.has(w));
const stemmed = filtered.map(w => {
try {
return stemmer.stem(w);
} catch {
return w;
}
});
return [...new Set([...hashtagTokens, ...stemmed])];
}
// Build docs from friends and events
export function buildSearchData(friends = []) {
const friendDocs = [];
const eventDocs = [];
const tagSet = new Set();
const personSet = new Set();
const friendMap = new Map();
for (const f of friends) friendMap.set(f.id, f);
for (const f of friends) {
personSet.add(f.name);
// Aggregate tags from events + friend notes + rich profile
const aggTags = new Set();
const addTagsFromAny = (val) => {
if (Array.isArray(val)) {
for (const item of val) {
for (const t of extractTagsFromText(String(item || ''))) aggTags.add(t);
}
} else {
for (const t of extractTagsFromText(String(val || ''))) aggTags.add(t);
}
};
addTagsFromAny(f.notes);
addTagsFromAny(f.likes);
addTagsFromAny(f.dislikes);
addTagsFromAny(f.foodLikes);
addTagsFromAny(f.foodDislikes);
addTagsFromAny(f.futureIdeas);
addTagsFromAny(f.quotes);
for (const ev of f.events || []) {
addTagsFromAny(ev.notes);
addTagsFromAny(ev.title);
}
// Compose an extended notes blob to improve recall
const profileBlob = [
f.notes,
Array.isArray(f.likes) ? f.likes.join(', ') : f.likes,
Array.isArray(f.dislikes) ? f.dislikes.join(', ') : f.dislikes,
f.foodLikes,
f.foodDislikes,
f.futureIdeas,
f.quotes,
f.workplace,
f.schedule,
f.carModel,
]
.filter(Boolean)
.join(' \\n');
const friendDoc = {
id: `friend:${f.id}`,
kind: 'friend',
refId: f.id,
name: f.name || '',
notes: profileBlob,
tags: Array.from(aggTags),
lastInteraction: f.lastInteraction || null,
};
friendDocs.push(friendDoc);
for (const t of friendDoc.tags) tagSet.add(t);
}
// Build de-duplicated events index from shared event ids
const dedup = new Map();
for (const f of friends) {
for (const e of f.events || []) {
if (!dedup.has(e.id)) {
dedup.set(e.id, {
...e,
participants: new Set(e.participants || []),
});
} else {
const cur = dedup.get(e.id);
for (const pid of e.participants || []) cur.participants.add(pid);
}
}
}
for (const e of dedup.values()) {
const names = [];
for (const pid of e.participants) {
const p = friendMap.get(pid);
if (p) names.push(p.name);
}
const tags = extractTagsFromText(e.notes || '');
for (const t of tags) tagSet.add(t);
eventDocs.push({
id: `event:${e.id}`,
kind: 'event',
refId: e.id,
title: e.title || '',
notes: e.notes || '',
location: e.location || '',
tags,
participantNames: names,
date: e.date,
});
}
return { friendDocs, eventDocs, tagSet: Array.from(tagSet), personSet: Array.from(personSet) };
}
function makeMiniSearch(docs, fields, storeFields, boosts = {}) {
const ms = new MiniSearch({
fields,
storeFields,
searchOptions: {
prefix: true,
fuzzy: 0.25,
boost: boosts,
extractField: (doc, fieldName) => {
const val = doc[fieldName];
if (Array.isArray(val)) return val.join(' ');
return String(val ?? '');
},
processTerm: (term, _field) => {
// Preserve hashtags as-is; otherwise stemming pipeline
if (term.startsWith('#')) return term;
const t = stripDiacritics(term.toLowerCase());
if (!t || FR_STOP.has(t)) return null;
try {
return stemmer.stem(t);
} catch {
return t;
}
},
},
});
ms.addAll(docs);
return ms;
}
export function buildSearchIndexes(friends = []) {
const { friendDocs, eventDocs, tagSet, personSet } = buildSearchData(friends);
const friendIndex = makeMiniSearch(friendDocs, ['name', 'notes', 'tags'], ['id', 'kind', 'refId', 'name', 'tags'], {
name: 3,
tags: 2,
});
const eventIndex = makeMiniSearch(
eventDocs,
['title', 'notes', 'location', 'tags', 'participantNames'],
['id', 'kind', 'refId', 'title', 'tags', 'participantNames', 'date'],
{ title: 3, tags: 2, participantNames: 1.5 }
);
return { friendIndex, eventIndex, tagSet, personSet, friendDocs, eventDocs };
}
// Faceted search with include/exclude tag/person filters
export function querySearch(indexes, query, facets = {}) {
const {
includeTags = new Set(),
excludeTags = new Set(),
includePersons = new Set(), // names
excludePersons = new Set(),
} = facets;
const q = String(query || '').trim();
const friendRes = q ? indexes.friendIndex.search(q) : indexes.friendDocs;
const eventRes = q ? indexes.eventIndex.search(q) : indexes.eventDocs;
const filterDoc = (doc) => {
const tags = new Set((doc.tags || []).map((t) => t.toLowerCase()));
// Persons only for events
const persons = new Set((doc.participantNames || []).map((n) => n.toLowerCase()));
// Includes
for (const t of includeTags) if (!tags.has(String(t).toLowerCase())) return false;
for (const p of includePersons) if (!persons.has(String(p).toLowerCase())) return false;
// Excludes
for (const t of excludeTags) if (tags.has(String(t).toLowerCase())) return false;
for (const p of excludePersons) if (persons.has(String(p).toLowerCase())) return false;
return true;
};
const friends = friendRes
.map((r) => (r.id ? indexes.friendDocs.find((d) => d.id === r.id) : r))
.filter(Boolean)
.filter(filterDoc);
const events = eventRes
.map((r) => (r.id ? indexes.eventDocs.find((d) => d.id === r.id) : r))
.filter(Boolean)
.filter(filterDoc);
return { friends, events };
}
// Suggestions for tags/persons
export function suggestTags(indexes, prefix = '') {
const p = String(prefix || '').toLowerCase().replace(/^#/, '');
if (!p) return indexes.tagSet.slice(0, 20);
return indexes.tagSet.filter((t) => t.startsWith(p)).slice(0, 20);
}
export function suggestPersons(indexes, prefix = '') {
const p = String(prefix || '').toLowerCase();
if (!p) return indexes.personSet.slice(0, 20);
return indexes.personSet.filter((name) => name.toLowerCase().startsWith(p)).slice(0, 20);
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -5,7 +5,12 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"preview:link": "vercel link --yes",
"preview:env:pull": "vercel env pull .env.local",
"deploy:preview": "vercel --yes",
"deploy:prod": "vercel --prod --yes",
"platform:build": "vercel build"
},
"engines": {
"node": ">=18.17.0"
......@@ -14,10 +19,13 @@
"@tailwindcss/aspect-ratio": "^0.4.2",
"bootstrap": "^5.3.3",
"classnames": "^2.5.1",
"d3-force": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^3.3.1",
"gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29",
"marked": "^15.0.11",
"minisearch": "^7.1.2",
"next": "^15.3.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
......@@ -30,10 +38,14 @@
"react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.0",
"remark-html": "^15.0.0",
"snowball-stemmers": "^0.6.0",
"stopword": "^3.1.5",
"swiper": "^11.2.6"
},
"devDependencies": {
"@types/react": "^18.2.61",
"typescript": "^5.3.3"
}
"typescript": "^5.3.3",
"vercel": "^39"
},
"packageManager": "yarn@4.1.0+sha512.5b7bc055cad63273dda27df1570a5d2eb4a9f03b35b394d3d55393c2a5560a17f5cef30944b11d6a48bcbcfc1c3a26d618aae77044774c529ba36cb771ad5b0f"
}
# Dunbar MVP v0.1 design
A # DUNBAR Social Network Navigation Assistant Implementation Guide.
## Executive Summary
DUNBAR is a privacy-first relationship management system based on Dunbar's number theory (5/15/50/150) expansion of human ability to nurture relationships -- a kind of social aug mod to multiply yourself. This guide provides stack-agnostic implementation requirements for recreating the validated prototype features.
## Core Data Model
### Friend Entity
```
Friend {
id: unique_identifier
name: string
relationships: Set<friend_id> // Bidirectional connections
events: Array<Event>
lastInteraction: date (computed from events)
}
```
### Event Entity
```
Event {
id: unique_identifier
date: date
notes: string (required)
location: string (optional)
participants: Array<friend_id> // For multi-friend events
}
```
### Persistence Requirements
- **MVP Password**: request browser-based classic password: "freehugs4all"
- **Local Storage**: All data must persist between sessions
- **Data Format**: Serialize Sets to Arrays for storage, reconstruct on load
- **Auto-save**: Save on every state change, no manual save required
## Feature Requirements
### 1. Friends List View
**Purpose**: Primary navigation and overview of all relationships
**Implementation**:
- Display all friends in scrollable list
- Show metadata per friend: `{event_count} events · {connection_count} connections`
- Click to navigate to friend detail view
- Visual indicator (arrow/chevron) showing clickable items
**Critical UX**:
- Hover states for better interactivity feedback
- Maintain scroll position when returning from detail view
### 2. Friend Detail View
**Purpose**: Manage individual friend's relationships and events
**Layout**: Two-column design
- Left column: Relationships management
- Right column: Events timeline + add event form
**Relationships Section**:
- List ALL other friends with toggle switches
- Toggle creates/removes bidirectional connection
- **Critical Bug Fix**: Preserve scroll position during toggle operations
- Store scrollTop before state update
- Restore scrollTop after DOM update (use setTimeout or nextTick)
- Show count in header: "Relationships (N)"
**Events Section**:
- Chronological list (newest first)
- Display format: Date on top, notes below
- Add Event form at bottom:
- Date picker (required)
- Multi-line text for notes (required)
- Submit button
### 3. Events Tab (Timeline View)
**Purpose**: Event-centric view for batch operations and timeline visualization
**Components**:
**New Event Creation**:
- Quick date buttons: "Today", "Yesterday", "Week Ago", "Start of Month"
- Multi-select friend list with:
- Search/filter box
- Checkbox per friend
- Visual highlight for selected friends
- Selected count display: "Friends (N selected)"
- Optional location field
- Required notes field
- Create button disabled until friends selected AND notes entered
**Timeline Display**:
- Group events by date
- Date headers with full format: "Monday, December 2, 2024"
- Each event shows:
- Friend name (bold)
- Event notes
- Location with pin emoji if present
- Visual hierarchy: Date > Friend > Details
### 4. Orbits Visualization
**Purpose**: Visual representation of relationship closeness based on interaction frequency
**Layout**:
- 3 concentric circles representing interaction levels
- Center point at viewport center
- Labels above each orbit
**Orbit Assignment Logic**:
```
Last 90 days events count:
- Inner orbit (5+ events): Close friends
- Middle orbit (2-4 events): Regular friends
- Outer orbit (0-1 events): Distant friends
```
**Node Rendering**:
- Distribute friends evenly around each orbit circumference
- Angle calculation: `2π / friend_count` per orbit
- Color coding by activity:
- Dark green (#2c5530): 5+ interactions
- Medium green (#5a9960): 2-4 interactions
- Light green (#a0c0a0): 0-1 interactions
**Interactivity**:
- **Click nodes** → Navigate to friend detail
- **Hover** → Show tooltip with:
- Friend name (bold)
- Total events count
- Connection count
- Last 3 events with format: "DATE: first three words..."
### 5. Network Graph
**Purpose**: Visualize and edit relationship connections
**Core Features**:
- Force-directed graph layout
- Node size proportional to connection count
- Color intensity based on connections:
- 10+ connections: Dark green
- 5-9 connections: Medium green
- 1-4 connections: Light green
- 0 connections: Gray
**Two Modes**:
**View Mode** (default):
- Click nodes → Navigate to friend detail
- Drag nodes → Reposition
- Scroll → Zoom
- Drag canvas → Pan
**Edit Mode** (toggled):
- Visual indicator: Border color change + button state
- Drag from node to node → Create/toggle connection
- Connections are always bidirectional
- Clear mode indicator: "Drag between nodes to create connections"
**Critical Implementation**:
- Node labels must be readable on all backgrounds:
- Use dark text (#333) always
- Add white stroke/outline for contrast
- Physics simulation for organic clustering
- Toggle physics on/off for performance
## State Management Patterns
### Data Flow
1. **Single source of truth**: Main friends array
2. **Derived states**: Calculate scores/orbits from events
3. **Bidirectional updates**: When toggling relationships, update both friends
### Update Triggers
- Use update counter or key props to force re-renders after state changes
- Critical for visualization updates after data modifications
### Performance Optimizations
- Memoize calculated values (interaction scores, event groupings)
- Limit orbit calculations to last 90 days
- Use Sets for relationship lookups (O(1) vs O(n))
## Critical UX Patterns
### Navigation Flow
```
Networks/Orbits (click node) → Set selected friend → Switch to List tab → Show detail
```
### Data Validation
- Prevent self-relationships
- Ensure bidirectional relationship consistency
- Require notes for events (not just date)
### Visual Feedback
- Disabled states for invalid inputs
- Active/hover states for all interactive elements
- Loading states for data processing
- Edit mode indicators
## Statistics Dashboard
Display four key metrics:
1. **Connections**: Total unique relationships / 2 (bidirectional)
2. **Active Friends**: Count with events in last 90 days
3. **Total Events**: Sum of all events across all friends
4. **Avg Events/Friend**: Total events / friend count
## Data Import/Export Considerations
### Reset Functionality
- Confirm dialog before clearing
- Complete localStorage wipe
- Reinitialize with empty state
### Future CSV Import
Structure to support:
```csv
Name,Met_Date,Met_Location,Community,Last_Interaction,Next_Interaction,Location,Notes
```
Auto-categorization logic:
- Rich profiles (notes + recent + future) → Inner circle
- Some data → Middle circle
- Minimal data → Outer circle
## Technical Constraints & Solutions
### Scroll Position Preservation
**Problem**: React re-renders reset scroll position
**Solution**:
```javascript
const scrollTop = containerRef.current.scrollTop;
updateState();
setTimeout(() => {
containerRef.current.scrollTop = scrollTop;
}, 0);
```
### Set Serialization
**Problem**: Sets can't be JSON stringified
**Solution**:
```javascript
// Save: Set → Array
relationships: Array.from(friendSet)
// Load: Array → Set
relationships: new Set(savedArray)
```
### Graph Library Selection
**Requirements**:
- Force-directed layout
- Interactive node positioning
- Zoom/pan controls
- Edit mode support
- Custom node styling
**Recommended features**:
- Physics simulation
- Collision detection
- Touch support for mobile
## Mobile Considerations
- Touch-friendly tap targets (minimum 44x44px)
- Swipe navigation between tabs
- Responsive graph scaling
- Bottom sheet pattern for add event form
## Privacy & Security
- All data stored locally only
- No external API calls
- No analytics or tracking
- Clear data ownership messaging
## Testing Checklist
### Core Functionality
- [ ] Add/remove bidirectional relationships
- [ ] Create events with multiple participants
- [ ] Navigate from graph nodes to details
- [ ] Data persists after refresh
- [ ] Scroll position maintained during updates
### Edge Cases
- [ ] 0 friends state
- [ ] 0 events state
- [ ] Maximum friends (150+) performance
- [ ] Circular relationship consistency
- [ ] Date boundary conditions
### Visual Validation
- [ ] Orbit distribution is even
- [ ] Network labels readable on all backgrounds
- [ ] Edit mode clearly indicated
- [ ] Responsive on various screen sizes
## Implementation Order (Recommended)
1. **Data Layer**: Models, storage, state management
2. **Friends List**: Basic CRUD, detail view
3. **Events System**: Single friend events first
4. **Persistence**: LocalStorage integration
5. **Orbits View**: Calculate positions, render, tooltips
6. **Network Graph**: Basic visualization
7. **Multi-friend Events**: Batch selection UI
8. **Network Editing**: Drag-to-connect functionality
9. **Polish**: Animations, performance, mobile
## Success Metrics
- Users can manage 150 relationships without performance degradation
- All state changes persist and sync across views
- Visual representations update in real-time
- Edit operations feel intuitive without instructions
- Data remains private and under user control
---
*This guide represents a validated MVP feature set. Focus on core functionality before adding enhancements.*
\ No newline at end of file
import Head from 'next/head';
import Layout from '@/components/layout';
import DunbarApp from '@/components/dunbar/DunbarApp';
// Client-only page; do not export getServerSideProps/getStaticProps
export default function DunbarEventPage() {
const desc =
'Dunbar — Event details in the privacy-first relationship navigator prototype. Local-only data, networks, events, and orbits.';
return (
<div className="container">
<Layout>
<Head>
<title>Dunbar Event</title>
<meta name="robots" content="noindex" />
<meta name="description" content={desc} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Dunbar — Event" />
<meta name="twitter:description" content={desc} />
<meta property="og:type" content="website" />
<meta property="og:title" content="Dunbar — Event" />
<meta property="og:description" content={desc} />
</Head>
<DunbarApp />
</Layout>
</div>
);
}
import Head from 'next/head';
import Layout from '@/components/layout';
import DunbarApp from '@/components/dunbar/DunbarApp';
export default function DunbarPage() {
return (
<div className="container">
<Layout>
<Head>
<title>Dunbar Relationship Navigator</title>
<meta name="robots" content="noindex" />
<meta
name="description"
content="Dunbar — a privacy-first relationship navigator prototype. Local-only data, no analytics, organize friends, events, and networks."
/>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Dunbar — Relationship Navigator" />
<meta
name="twitter:description"
content="Privacy-first relationship navigator prototype. Local-only data, networks, events, and orbits."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="Dunbar — Relationship Navigator" />
<meta
property="og:description"
content="Privacy-first relationship navigator prototype. Local-only data, networks, events, and orbits."
/>
</Head>
<DunbarApp />
</Layout>
</div>
);
}
......@@ -67,10 +67,10 @@ export default function ParVagues({ lives }) {
const backgroundRef = useRef(null);
const audioRef = useRef(null);
// Filter future events
// Filter future events and sort them by date in ascending order
const futureEvents = lives.filter(live => {
return new Date(live.date) > new Date();
});
}).sort((a, b) => new Date(a.date) - new Date(b.date));
// Define section content
const sections = {
......@@ -120,6 +120,20 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12)
}
}, [selectedSection]);
// Auto-toggling for sections (Potentiel, Composition, Performance)
const sectionOrder = ['potentiel', 'composition', 'performance'];
useEffect(() => {
const intervalId = setInterval(() => {
setSelectedSection(currentSection => {
const currentIndex = sectionOrder.indexOf(currentSection);
const nextIndex = (currentIndex + 1) % sectionOrder.length;
return sectionOrder[nextIndex];
});
}, 5000); // 5 seconds
return () => clearInterval(intervalId); // Cleanup on component unmount or when selectedSection changes
}, [selectedSection]); // Re-run effect (and reset timer) when selectedSection changes
// Auto-advance carousel for section images
useEffect(() => {
if (sections[selectedSection]?.images?.length > 1) {
......@@ -176,7 +190,10 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12)
}
};
const albums = [
// Define the desired order of platforms
const platformOrder = ['YouTube', 'Deezer', 'Spotify', 'Apple', 'Tidal', 'Amazon'];
const albumsData = [
{
id: '2024_opal',
title: 'Livecoding (Opal Festival 2024)',
......@@ -204,6 +221,19 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12)
}
];
// Sort the links for each album
const albums = albumsData.map(album => ({
...album,
links: album.links.sort((a, b) => {
const indexA = platformOrder.indexOf(a.platform);
const indexB = platformOrder.indexOf(b.platform);
// If a platform is not in platformOrder, keep its relative order towards the end
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
})
}));
const renderSectionContent = () => {
const sectionContent = sections[selectedSection];
if (!sectionContent) return null;
......@@ -344,36 +374,36 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12)
<div className={styles.splitSection}>
<div>
<div
className={`cursor-pointer transition-all duration-300 hover:bg-purple-500/10 rounded-lg p-2 ${selectedSection === 'potentiel' ? 'text-purple-400 border-l-2 border-purple-400 pl-4' : ''}`}
className={`cursor-pointer transition-all duration-300 hover:bg-purple-500/10 rounded-lg p-2 ${selectedSection === 'potentiel' ? 'text-purple-400 border-l-2 border-purple-400 pl-4' : 'text-gray-400 border-l-2 border-transparent'}`}
onClick={() => setSelectedSection('potentiel')}
>
<h3 className={`${styles.bulletPoint} text-xl font-semibold text-purple-400 mb-2 group relative inline-block`} style={{ margin: '1em 0', textDecorationLine: 'underline', textDecorationColor: 'darkviolet', textDecorationThickness: '3px' }}>
<h3 className={`${styles.bulletPoint} text-xl font-semibold mb-2 group relative inline-block ${selectedSection === 'potentiel' ? 'text-purple-400' : 'text-gray-400'}`} style={{ margin: '1em 0', textDecorationLine: 'underline', textDecorationColor: 'darkviolet', textDecorationThickness: '3px' }}>
Potentiel
<span className="absolute bottom-0 left-0 w-0 h-0.5 bg-gradient-to-r from-purple-400 to-pink-500 group-hover:w-full transition-all duration-300"></span>
</h3>
<p className="text-gray-300">Samples glanés et synthés SuperCollider</p>
<p className={`${selectedSection === 'potentiel' ? 'text-gray-300' : 'text-gray-500'}`}>Samples glanés et synthés SuperCollider</p>
</div>
<div
className={`cursor-pointer transition-all duration-300 hover:bg-purple-500/10 rounded-lg p-2 ${selectedSection === 'composition' ? 'text-purple-400 border-l-2 border-purple-400 pl-4' : ''}`}
className={`cursor-pointer transition-all duration-300 hover:bg-purple-500/10 rounded-lg p-2 ${selectedSection === 'composition' ? 'text-purple-400 border-l-2 border-purple-400 pl-4' : 'text-gray-400 border-l-2 border-transparent'}`}
onClick={() => setSelectedSection('composition')}
>
<h3 className={`${styles.bulletPoint} text-xl font-semibold text-purple-400 mb-2 group relative inline-block`} style={{ margin: '1em 0', textDecorationLine: 'underline', textDecorationColor: 'darkviolet', textDecorationThickness: '3px' }}>
<h3 className={`${styles.bulletPoint} text-xl font-semibold mb-2 group relative inline-block ${selectedSection === 'composition' ? 'text-purple-400' : 'text-gray-400'}`} style={{ margin: '1em 0', textDecorationLine: 'underline', textDecorationColor: 'darkviolet', textDecorationThickness: '3px' }}>
Composition
<span className="absolute bottom-0 left-0 w-0 h-0.5 bg-gradient-to-r from-purple-400 to-pink-500 group-hover:w-full transition-all duration-300"></span>
</h3>
<p className="text-gray-300">Code Haskell TidalCycles + input MIDI</p>
<p className={`${selectedSection === 'composition' ? 'text-gray-300' : 'text-gray-500'}`}>Code Haskell TidalCycles + input MIDI</p>
</div>
<div
className={`cursor-pointer transition-all duration-300 hover:bg-purple-500/10 rounded-lg p-2 ${selectedSection === 'performance' ? 'text-purple-400 border-l-2 border-purple-400 pl-4' : ''}`}
className={`cursor-pointer transition-all duration-300 hover:bg-purple-500/10 rounded-lg p-2 ${selectedSection === 'performance' ? 'text-purple-400 border-l-2 border-purple-400 pl-4' : 'text-gray-400 border-l-2 border-transparent'}`}
onClick={() => setSelectedSection('performance')}
>
<h3 className={`${styles.bulletPoint} text-xl font-semibold text-purple-400 mb-2 group relative inline-block`} style={{ margin: '1em 0', textDecorationLine: 'underline', textDecorationColor: 'darkviolet', textDecorationThickness: '3px' }}>
<h3 className={`${styles.bulletPoint} text-xl font-semibold mb-2 group relative inline-block ${selectedSection === 'performance' ? 'text-purple-400' : 'text-gray-400'}`} style={{ margin: '1em 0', textDecorationLine: 'underline', textDecorationColor: 'darkviolet', textDecorationThickness: '3px' }}>
Performance
<span className="absolute bottom-0 left-0 w-0 h-0.5 bg-gradient-to-r from-purple-400 to-pink-500 group-hover:w-full transition-all duration-300"></span>
</h3>
<p className="text-gray-300">Performance live avec improvisation au contrôleur MIDI</p>
<p className={`${selectedSection === 'performance' ? 'text-gray-300' : 'text-gray-500'}`}>Performance live avec improvisation au contrôleur MIDI</p>
</div>
</div>
......
/* Dunbar MVP styles (scoped via CSS Modules) */
.container {
padding: 16px;
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.title {
font-size: 1.5rem;
font-weight: 700;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.spacer {
flex: 1;
}
.tabs {
display: flex;
gap: 8px;
margin: 8px 0 16px;
flex-wrap: wrap;
}
.tabBtn {
padding: 8px 12px;
border: 1px solid #ddd;
background: #fafafa;
border-radius: 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.tabBtn:hover {
background: #f0f0f0;
}
.tabActive {
background: #e7f5ec;
border-color: #5a9960;
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.input, .textarea, .select {
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px 10px;
font-size: 0.95rem;
background: #fff;
}
.textarea {
min-height: 80px;
resize: vertical;
}
.btn {
padding: 8px 12px;
border: 1px solid #222;
background: #222;
color: #fff;
border-radius: 8px;
cursor: pointer;
transition: background 120ms ease, opacity 120ms ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnSecondary {
padding: 8px 12px;
border: 1px solid #ddd;
background: #fff;
color: #333;
border-radius: 8px;
cursor: pointer;
}
.list {
border: 1px solid #eee;
border-radius: 10px;
overflow: hidden;
}
.listScroll {
max-height: 60vh;
overflow: auto;
}
.listItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #f2f2f2;
cursor: pointer;
background: #fff;
transition: background 120ms ease;
}
.listItem:hover {
background: #f9f9f9;
}
.itemTitle {
font-weight: 600;
}
.itemMeta {
color: #666;
font-size: 0.9rem;
}
.itemRight {
margin-left: auto;
color: #aaa;
}
.twoCol {
display: grid;
grid-template-columns: 1fr 1.8fr;
gap: 16px;
}
@media (max-width: 900px) {
.twoCol {
grid-template-columns: 1fr;
}
}
.card {
border: 1px solid #eee;
background: #fff;
border-radius: 10px;
padding: 12px;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 600;
}
.scroll {
max-height: 60vh;
overflow: auto;
}
.switchRow {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 6px;
border-bottom: 1px solid #f5f5f5;
}
.switchRow:hover {
background: #fafafa;
}
.switch {
width: 42px;
height: 24px;
background: #ddd;
border-radius: 999px;
position: relative;
transition: background 120ms ease;
}
.switchOn {
background: #5a9960;
}
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
transition: left 120ms ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
}
.knobOn {
left: 21px;
}
.timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.timelineGroup {
margin: 8px 0;
}
.timelineDate {
font-weight: 700;
margin-bottom: 6px;
}
.timelineEvent {
background: #fbfbfb;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 8px 10px;
}
.tagRow {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.tagChip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: #eef7f0;
border: 1px solid #d6e8da;
color: #2c5530;
font-size: 0.8rem;
line-height: 1.4;
gap: 6px;
}
.tagClose {
appearance: none;
border: none;
background: transparent;
color: #2c5530;
font-weight: 800;
cursor: pointer;
padding: 0;
line-height: 1;
}
.badge {
display: inline-block;
padding: 2px 6px;
border-radius: 999px;
background: #eef7f0;
color: #2c5530;
font-size: 0.8rem;
border: 1px solid #d6e8da;
}
.tooltip {
position: fixed;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
padding: 8px 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
pointer-events: none;
z-index: 1000;
max-width: 280px;
font-size: 0.9rem;
}
.graphToolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
.banner {
padding: 8px 10px;
background: #fffbea;
border: 1px solid #fde68a;
color: #7c5e10;
border-radius: 8px;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr));
gap: 12px;
}
@media (max-width: 700px) {
.statsGrid {
grid-template-columns: repeat(2, minmax(140px, 1fr));
}
}
.statCard {
border: 1px solid #eee;
background: #fff;
border-radius: 10px;
padding: 12px;
}
.statLabel {
color: #666;
font-size: 0.9rem;
}
.statValue {
font-size: 1.6rem;
font-weight: 800;
}
/* Orbits */
.orbitsWrap {
width: 100%;
height: 70vh;
border: 1px solid #eee;
border-radius: 10px;
overflow: hidden;
background: radial-gradient(circle at center, #ffffff 0%, #f7fbf8 100%);
}
.orbitLabel {
fill: #2c5530;
font-size: 12px;
font-weight: 700;
}
.nodeLabel {
fill: #333;
font-weight: 700;
paint-order: stroke;
stroke: #fff;
stroke-width: 3px;
stroke-linejoin: round;
}
/* Network */
.canvasWrap {
width: 100%;
height: 70vh;
border: 1px solid #eee;
border-radius: 10px;
overflow: hidden;
background: #fff;
position: relative;
}
.chevron {
font-size: 14px;
color: #999;
}
/* Password screen */
.lockWrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
flex-direction: column;
gap: 10px;
text-align: center;
}
/* Floating controls for Network */
.floatingControls {
position: absolute;
right: 10px;
bottom: 10px;
display: grid;
grid-template-columns: repeat(3, 40px);
gap: 6px;
background: rgba(255,255,255,0.9);
border: 1px solid #eee;
border-radius: 10px;
padding: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.ctrlBtn {
width: 40px;
height: 40px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #333;
transition: transform 80ms ease, background 120ms ease;
}
.ctrlBtn:active {
transform: scale(0.96);
background: #f6f6f6;
}
.ctrlWide {
grid-column: span 3;
height: 36px;
}
......@@ -168,13 +168,14 @@
box-shadow: 0 10px 20px rgba(217, 0, 255, 0.3);
}
/* This is the .outlineButton style used by the header's Book button */
.outlineButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1rem 2rem;
border-radius: 0.5rem;
font-weight: 600;
/* padding is controlled by Tailwind classes in the component (py-2 px-3 sm:px-4) */
border-radius: 0.375rem; /* Tailwind's rounded-md */
font-weight: 500; /* Tailwind's font-medium equivalent */
text-decoration: none;
color: white;
border: 1px solid rgba(217, 0, 255, 0.5);
......@@ -188,8 +189,8 @@
background: rgba(217, 0, 255, 0.2);
border-color: var(--neon-high);
color: var(--neon-high);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(217, 0, 255, 0.2);
transform: translateY(-1px); /* Subtle lift */
box-shadow: 0 3px 10px rgba(217, 0, 255, 0.2); /* Subtle shadow */
}
.sectionContainer {
......@@ -562,5 +563,106 @@ AS MODULE CSS FORBIDS ROOT VARIABLES
}
img.live-gallery-image {
max-width: 1em;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Image Gallery Styles */
.galleryGrid {
display: flex;
margin-left: -1rem; /* gutter size offset */
width: auto;
position: relative; /* Added for Masonry to correctly position its items */
}
.galleryGridColumn {
padding-left: 1rem; /* gutter size */
background-clip: padding-box;
/* Ensure columns establish a formatting context for their children if needed */
/* display: block; /* This is default for divs, but can be explicit */
}
.galleryCard {
/* Base card styling if needed beyond Tailwind */
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.galleryCard:hover {
transform: translateY(-5px);
}
.pngBackground {
background-color: #e9e9e9; /* Light gray background for PNG cards */
}
.pngBackgroundModal {
background-color: #cccccc; /* Slightly darker gray for modal PNG background for contrast */
}
.modalOverlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000; /* High z-index */
padding: 1rem;
}
.modalContent {
position: relative;
padding: 1rem; /* Padding around the image in modal */
border-radius: 0.5rem; /* Rounded corners for the modal content box */
max-width: 95vw;
max-height: 95vh;
display: flex; /* Allow image to determine size up to max */
align-items: center;
justify-content: center;
}
.modalCloseButton {
position: absolute;
top: -10px; /* Position outside the content padding */
right: -5px;
background: rgba(30, 30, 30, 0.8);
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
font-size: 1.5rem;
line-height: 28px; /* Vertically center times symbol */
text-align: center;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
}
.modalCloseButton:hover {
background-color: var(--neon-high); /* Use theme color */
color: black;
}
/* Styles for the ParVaguesHeader */
.headerContainer {
/* This class is applied to the <header> element. */
/* Tailwind classes already handle sticky, z-index, background, border. */
/* No specific additional styles needed here for the new layout. */
}
.navLink {
/* This class is applied to individual navigation Links (<a> tags). */
/* Tailwind classes handle text color, hover, transition, padding. */
/* No specific additional styles needed here unless further customization is desired. */
/* e.g., text-decoration: none; (though Next/Link handles this) */
}
/* .bookButton is a supplementary class for the CTA Link. */
/* It's used alongside .outlineButton. */
/* .outlineButton provides the base style, .bookButton can be for specific tweaks. */
.bookButton {
/* Tailwind classes handle padding, font size, flex items, whitespace. */
/* No specific additional styles needed here for the new layout. */
}
This source diff could not be displayed because it is too large. You can view the blob instead.
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