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: globs:
alwaysApply: true alwaysApply: true
--- ---
# Use @/ syntax for imports in Next.js projects # Use @/ syntax for imports in Next.js projects
- Always use `@/components/xxx` syntax, not the `../../components/xxx` syntax. - Always use `@/components/...`, `@/lib/...`, etc. Avoid long `../../` relative paths.
\ No newline at end of file - 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: description: Enforce Next.js global CSS rule — only import global CSS from pages/_app.js
globs: *.css globs:
alwaysApply: false alwaysApply: true
--- ---
# AVoid global css: # Avoid global CSS outside Custom App
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). - 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/ node_modules/
.vercel .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* ...@@ -31,3 +31,4 @@ yarn-error.log*
# LLM exchanges # LLM exchanges
code2prompt.json code2prompt.json
.vercel
This source diff could not be displayed because it is too large. You can view the blob instead.
import fs from 'fs';
import { URL as URL$1, fileURLToPath, pathToFileURL } from 'url';
import path from 'path';
import { createHash } from 'crypto';
import { EOL } from 'os';
import moduleExports, { isBuiltin } from 'module';
import assert from 'assert';
const SAFE_TIME = 456789e3;
const PortablePath = {
root: `/`,
dot: `.`,
parent: `..`
};
const npath = Object.create(path);
const ppath = Object.create(path.posix);
npath.cwd = () => process.cwd();
ppath.cwd = process.platform === `win32` ? () => toPortablePath(process.cwd()) : process.cwd;
if (process.platform === `win32`) {
ppath.resolve = (...segments) => {
if (segments.length > 0 && ppath.isAbsolute(segments[0])) {
return path.posix.resolve(...segments);
} else {
return path.posix.resolve(ppath.cwd(), ...segments);
}
};
}
const contains = function(pathUtils, from, to) {
from = pathUtils.normalize(from);
to = pathUtils.normalize(to);
if (from === to)
return `.`;
if (!from.endsWith(pathUtils.sep))
from = from + pathUtils.sep;
if (to.startsWith(from)) {
return to.slice(from.length);
} else {
return null;
}
};
npath.contains = (from, to) => contains(npath, from, to);
ppath.contains = (from, to) => contains(ppath, from, to);
const WINDOWS_PATH_REGEXP = /^([a-zA-Z]:.*)$/;
const UNC_WINDOWS_PATH_REGEXP = /^\/\/(\.\/)?(.*)$/;
const PORTABLE_PATH_REGEXP = /^\/([a-zA-Z]:.*)$/;
const UNC_PORTABLE_PATH_REGEXP = /^\/unc\/(\.dot\/)?(.*)$/;
function fromPortablePathWin32(p) {
let portablePathMatch, uncPortablePathMatch;
if (portablePathMatch = p.match(PORTABLE_PATH_REGEXP))
p = portablePathMatch[1];
else if (uncPortablePathMatch = p.match(UNC_PORTABLE_PATH_REGEXP))
p = `\\\\${uncPortablePathMatch[1] ? `.\\` : ``}${uncPortablePathMatch[2]}`;
else
return p;
return p.replace(/\//g, `\\`);
}
function toPortablePathWin32(p) {
p = p.replace(/\\/g, `/`);
let windowsPathMatch, uncWindowsPathMatch;
if (windowsPathMatch = p.match(WINDOWS_PATH_REGEXP))
p = `/${windowsPathMatch[1]}`;
else if (uncWindowsPathMatch = p.match(UNC_WINDOWS_PATH_REGEXP))
p = `/unc/${uncWindowsPathMatch[1] ? `.dot/` : ``}${uncWindowsPathMatch[2]}`;
return p;
}
const toPortablePath = process.platform === `win32` ? toPortablePathWin32 : (p) => p;
const fromPortablePath = process.platform === `win32` ? fromPortablePathWin32 : (p) => p;
npath.fromPortablePath = fromPortablePath;
npath.toPortablePath = toPortablePath;
function convertPath(targetPathUtils, sourcePath) {
return targetPathUtils === npath ? fromPortablePath(sourcePath) : toPortablePath(sourcePath);
}
const defaultTime = new Date(SAFE_TIME * 1e3);
const defaultTimeMs = defaultTime.getTime();
async function copyPromise(destinationFs, destination, sourceFs, source, opts) {
const normalizedDestination = destinationFs.pathUtils.normalize(destination);
const normalizedSource = sourceFs.pathUtils.normalize(source);
const prelayout = [];
const postlayout = [];
const { atime, mtime } = opts.stableTime ? { atime: defaultTime, mtime: defaultTime } : await sourceFs.lstatPromise(normalizedSource);
await destinationFs.mkdirpPromise(destinationFs.pathUtils.dirname(destination), { utimes: [atime, mtime] });
await copyImpl(prelayout, postlayout, destinationFs, normalizedDestination, sourceFs, normalizedSource, { ...opts, didParentExist: true });
for (const operation of prelayout)
await operation();
await Promise.all(postlayout.map((operation) => {
return operation();
}));
}
async function copyImpl(prelayout, postlayout, destinationFs, destination, sourceFs, source, opts) {
const destinationStat = opts.didParentExist ? await maybeLStat(destinationFs, destination) : null;
const sourceStat = await sourceFs.lstatPromise(source);
const { atime, mtime } = opts.stableTime ? { atime: defaultTime, mtime: defaultTime } : sourceStat;
let updated;
switch (true) {
case sourceStat.isDirectory():
{
updated = await copyFolder(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts);
}
break;
case sourceStat.isFile():
{
updated = await copyFile(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts);
}
break;
case sourceStat.isSymbolicLink():
{
updated = await copySymlink(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts);
}
break;
default: {
throw new Error(`Unsupported file type (${sourceStat.mode})`);
}
}
if (opts.linkStrategy?.type !== `HardlinkFromIndex` || !sourceStat.isFile()) {
if (updated || destinationStat?.mtime?.getTime() !== mtime.getTime() || destinationStat?.atime?.getTime() !== atime.getTime()) {
postlayout.push(() => destinationFs.lutimesPromise(destination, atime, mtime));
updated = true;
}
if (destinationStat === null || (destinationStat.mode & 511) !== (sourceStat.mode & 511)) {
postlayout.push(() => destinationFs.chmodPromise(destination, sourceStat.mode & 511));
updated = true;
}
}
return updated;
}
async function maybeLStat(baseFs, p) {
try {
return await baseFs.lstatPromise(p);
} catch (e) {
return null;
}
}
async function copyFolder(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) {
if (destinationStat !== null && !destinationStat.isDirectory()) {
if (opts.overwrite) {
prelayout.push(async () => destinationFs.removePromise(destination));
destinationStat = null;
} else {
return false;
}
}
let updated = false;
if (destinationStat === null) {
prelayout.push(async () => {
try {
await destinationFs.mkdirPromise(destination, { mode: sourceStat.mode });
} catch (err) {
if (err.code !== `EEXIST`) {
throw err;
}
}
});
updated = true;
}
const entries = await sourceFs.readdirPromise(source);
const nextOpts = opts.didParentExist && !destinationStat ? { ...opts, didParentExist: false } : opts;
if (opts.stableSort) {
for (const entry of entries.sort()) {
if (await copyImpl(prelayout, postlayout, destinationFs, destinationFs.pathUtils.join(destination, entry), sourceFs, sourceFs.pathUtils.join(source, entry), nextOpts)) {
updated = true;
}
}
} else {
const entriesUpdateStatus = await Promise.all(entries.map(async (entry) => {
await copyImpl(prelayout, postlayout, destinationFs, destinationFs.pathUtils.join(destination, entry), sourceFs, sourceFs.pathUtils.join(source, entry), nextOpts);
}));
if (entriesUpdateStatus.some((status) => status)) {
updated = true;
}
}
return updated;
}
async function copyFileViaIndex(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts, linkStrategy) {
const sourceHash = await sourceFs.checksumFilePromise(source, { algorithm: `sha1` });
const defaultMode = 420;
const sourceMode = sourceStat.mode & 511;
const indexFileName = `${sourceHash}${sourceMode !== defaultMode ? sourceMode.toString(8) : ``}`;
const indexPath = destinationFs.pathUtils.join(linkStrategy.indexPath, sourceHash.slice(0, 2), `${indexFileName}.dat`);
let AtomicBehavior;
((AtomicBehavior2) => {
AtomicBehavior2[AtomicBehavior2["Lock"] = 0] = "Lock";
AtomicBehavior2[AtomicBehavior2["Rename"] = 1] = "Rename";
})(AtomicBehavior || (AtomicBehavior = {}));
let atomicBehavior = 1 /* Rename */;
let indexStat = await maybeLStat(destinationFs, indexPath);
if (destinationStat) {
const isDestinationHardlinkedFromIndex = indexStat && destinationStat.dev === indexStat.dev && destinationStat.ino === indexStat.ino;
const isIndexModified = indexStat?.mtimeMs !== defaultTimeMs;
if (isDestinationHardlinkedFromIndex) {
if (isIndexModified && linkStrategy.autoRepair) {
atomicBehavior = 0 /* Lock */;
indexStat = null;
}
}
if (!isDestinationHardlinkedFromIndex) {
if (opts.overwrite) {
prelayout.push(async () => destinationFs.removePromise(destination));
destinationStat = null;
} else {
return false;
}
}
}
const tempPath = !indexStat && atomicBehavior === 1 /* Rename */ ? `${indexPath}.${Math.floor(Math.random() * 4294967296).toString(16).padStart(8, `0`)}` : null;
let tempPathCleaned = false;
prelayout.push(async () => {
if (!indexStat) {
if (atomicBehavior === 0 /* Lock */) {
await destinationFs.lockPromise(indexPath, async () => {
const content = await sourceFs.readFilePromise(source);
await destinationFs.writeFilePromise(indexPath, content);
});
}
if (atomicBehavior === 1 /* Rename */ && tempPath) {
const content = await sourceFs.readFilePromise(source);
await destinationFs.writeFilePromise(tempPath, content);
try {
await destinationFs.linkPromise(tempPath, indexPath);
} catch (err) {
if (err.code === `EEXIST`) {
tempPathCleaned = true;
await destinationFs.unlinkPromise(tempPath);
} else {
throw err;
}
}
}
}
if (!destinationStat) {
await destinationFs.linkPromise(indexPath, destination);
}
});
postlayout.push(async () => {
if (!indexStat) {
await destinationFs.lutimesPromise(indexPath, defaultTime, defaultTime);
if (sourceMode !== defaultMode) {
await destinationFs.chmodPromise(indexPath, sourceMode);
}
}
if (tempPath && !tempPathCleaned) {
await destinationFs.unlinkPromise(tempPath);
}
});
return false;
}
async function copyFileDirect(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) {
if (destinationStat !== null) {
if (opts.overwrite) {
prelayout.push(async () => destinationFs.removePromise(destination));
destinationStat = null;
} else {
return false;
}
}
prelayout.push(async () => {
const content = await sourceFs.readFilePromise(source);
await destinationFs.writeFilePromise(destination, content);
});
return true;
}
async function copyFile(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) {
if (opts.linkStrategy?.type === `HardlinkFromIndex`) {
return copyFileViaIndex(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts, opts.linkStrategy);
} else {
return copyFileDirect(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts);
}
}
async function copySymlink(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) {
if (destinationStat !== null) {
if (opts.overwrite) {
prelayout.push(async () => destinationFs.removePromise(destination));
destinationStat = null;
} else {
return false;
}
}
prelayout.push(async () => {
await destinationFs.symlinkPromise(convertPath(destinationFs.pathUtils, await sourceFs.readlinkPromise(source)), destination);
});
return true;
}
class FakeFS {
constructor(pathUtils) {
this.pathUtils = pathUtils;
}
async *genTraversePromise(init, { stableSort = false } = {}) {
const stack = [init];
while (stack.length > 0) {
const p = stack.shift();
const entry = await this.lstatPromise(p);
if (entry.isDirectory()) {
const entries = await this.readdirPromise(p);
if (stableSort) {
for (const entry2 of entries.sort()) {
stack.push(this.pathUtils.join(p, entry2));
}
} else {
throw new Error(`Not supported`);
}
} else {
yield p;
}
}
}
async checksumFilePromise(path, { algorithm = `sha512` } = {}) {
const fd = await this.openPromise(path, `r`);
try {
const CHUNK_SIZE = 65536;
const chunk = Buffer.allocUnsafeSlow(CHUNK_SIZE);
const hash = createHash(algorithm);
let bytesRead = 0;
while ((bytesRead = await this.readPromise(fd, chunk, 0, CHUNK_SIZE)) !== 0)
hash.update(bytesRead === CHUNK_SIZE ? chunk : chunk.slice(0, bytesRead));
return hash.digest(`hex`);
} finally {
await this.closePromise(fd);
}
}
async removePromise(p, { recursive = true, maxRetries = 5 } = {}) {
let stat;
try {
stat = await this.lstatPromise(p);
} catch (error) {
if (error.code === `ENOENT`) {
return;
} else {
throw error;
}
}
if (stat.isDirectory()) {
if (recursive) {
const entries = await this.readdirPromise(p);
await Promise.all(entries.map((entry) => {
return this.removePromise(this.pathUtils.resolve(p, entry));
}));
}
for (let t = 0; t <= maxRetries; t++) {
try {
await this.rmdirPromise(p);
break;
} catch (error) {
if (error.code !== `EBUSY` && error.code !== `ENOTEMPTY`) {
throw error;
} else if (t < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, t * 100));
}
}
}
} else {
await this.unlinkPromise(p);
}
}
removeSync(p, { recursive = true } = {}) {
let stat;
try {
stat = this.lstatSync(p);
} catch (error) {
if (error.code === `ENOENT`) {
return;
} else {
throw error;
}
}
if (stat.isDirectory()) {
if (recursive)
for (const entry of this.readdirSync(p))
this.removeSync(this.pathUtils.resolve(p, entry));
this.rmdirSync(p);
} else {
this.unlinkSync(p);
}
}
async mkdirpPromise(p, { chmod, utimes } = {}) {
p = this.resolve(p);
if (p === this.pathUtils.dirname(p))
return void 0;
const parts = p.split(this.pathUtils.sep);
let createdDirectory;
for (let u = 2; u <= parts.length; ++u) {
const subPath = parts.slice(0, u).join(this.pathUtils.sep);
if (!this.existsSync(subPath)) {
try {
await this.mkdirPromise(subPath);
} catch (error) {
if (error.code === `EEXIST`) {
continue;
} else {
throw error;
}
}
createdDirectory ??= subPath;
if (chmod != null)
await this.chmodPromise(subPath, chmod);
if (utimes != null) {
await this.utimesPromise(subPath, utimes[0], utimes[1]);
} else {
const parentStat = await this.statPromise(this.pathUtils.dirname(subPath));
await this.utimesPromise(subPath, parentStat.atime, parentStat.mtime);
}
}
}
return createdDirectory;
}
mkdirpSync(p, { chmod, utimes } = {}) {
p = this.resolve(p);
if (p === this.pathUtils.dirname(p))
return void 0;
const parts = p.split(this.pathUtils.sep);
let createdDirectory;
for (let u = 2; u <= parts.length; ++u) {
const subPath = parts.slice(0, u).join(this.pathUtils.sep);
if (!this.existsSync(subPath)) {
try {
this.mkdirSync(subPath);
} catch (error) {
if (error.code === `EEXIST`) {
continue;
} else {
throw error;
}
}
createdDirectory ??= subPath;
if (chmod != null)
this.chmodSync(subPath, chmod);
if (utimes != null) {
this.utimesSync(subPath, utimes[0], utimes[1]);
} else {
const parentStat = this.statSync(this.pathUtils.dirname(subPath));
this.utimesSync(subPath, parentStat.atime, parentStat.mtime);
}
}
}
return createdDirectory;
}
async copyPromise(destination, source, { baseFs = this, overwrite = true, stableSort = false, stableTime = false, linkStrategy = null } = {}) {
return await copyPromise(this, destination, baseFs, source, { overwrite, stableSort, stableTime, linkStrategy });
}
copySync(destination, source, { baseFs = this, overwrite = true } = {}) {
const stat = baseFs.lstatSync(source);
const exists = this.existsSync(destination);
if (stat.isDirectory()) {
this.mkdirpSync(destination);
const directoryListing = baseFs.readdirSync(source);
for (const entry of directoryListing) {
this.copySync(this.pathUtils.join(destination, entry), baseFs.pathUtils.join(source, entry), { baseFs, overwrite });
}
} else if (stat.isFile()) {
if (!exists || overwrite) {
if (exists)
this.removeSync(destination);
const content = baseFs.readFileSync(source);
this.writeFileSync(destination, content);
}
} else if (stat.isSymbolicLink()) {
if (!exists || overwrite) {
if (exists)
this.removeSync(destination);
const target = baseFs.readlinkSync(source);
this.symlinkSync(convertPath(this.pathUtils, target), destination);
}
} else {
throw new Error(`Unsupported file type (file: ${source}, mode: 0o${stat.mode.toString(8).padStart(6, `0`)})`);
}
const mode = stat.mode & 511;
this.chmodSync(destination, mode);
}
async changeFilePromise(p, content, opts = {}) {
if (Buffer.isBuffer(content)) {
return this.changeFileBufferPromise(p, content, opts);
} else {
return this.changeFileTextPromise(p, content, opts);
}
}
async changeFileBufferPromise(p, content, { mode } = {}) {
let current = Buffer.alloc(0);
try {
current = await this.readFilePromise(p);
} catch (error) {
}
if (Buffer.compare(current, content) === 0)
return;
await this.writeFilePromise(p, content, { mode });
}
async changeFileTextPromise(p, content, { automaticNewlines, mode } = {}) {
let current = ``;
try {
current = await this.readFilePromise(p, `utf8`);
} catch (error) {
}
const normalizedContent = automaticNewlines ? normalizeLineEndings(current, content) : content;
if (current === normalizedContent)
return;
await this.writeFilePromise(p, normalizedContent, { mode });
}
changeFileSync(p, content, opts = {}) {
if (Buffer.isBuffer(content)) {
return this.changeFileBufferSync(p, content, opts);
} else {
return this.changeFileTextSync(p, content, opts);
}
}
changeFileBufferSync(p, content, { mode } = {}) {
let current = Buffer.alloc(0);
try {
current = this.readFileSync(p);
} catch (error) {
}
if (Buffer.compare(current, content) === 0)
return;
this.writeFileSync(p, content, { mode });
}
changeFileTextSync(p, content, { automaticNewlines = false, mode } = {}) {
let current = ``;
try {
current = this.readFileSync(p, `utf8`);
} catch (error) {
}
const normalizedContent = automaticNewlines ? normalizeLineEndings(current, content) : content;
if (current === normalizedContent)
return;
this.writeFileSync(p, normalizedContent, { mode });
}
async movePromise(fromP, toP) {
try {
await this.renamePromise(fromP, toP);
} catch (error) {
if (error.code === `EXDEV`) {
await this.copyPromise(toP, fromP);
await this.removePromise(fromP);
} else {
throw error;
}
}
}
moveSync(fromP, toP) {
try {
this.renameSync(fromP, toP);
} catch (error) {
if (error.code === `EXDEV`) {
this.copySync(toP, fromP);
this.removeSync(fromP);
} else {
throw error;
}
}
}
async lockPromise(affectedPath, callback) {
const lockPath = `${affectedPath}.flock`;
const interval = 1e3 / 60;
const startTime = Date.now();
let fd = null;
const isAlive = async () => {
let pid;
try {
[pid] = await this.readJsonPromise(lockPath);
} catch (error) {
return Date.now() - startTime < 500;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
};
while (fd === null) {
try {
fd = await this.openPromise(lockPath, `wx`);
} catch (error) {
if (error.code === `EEXIST`) {
if (!await isAlive()) {
try {
await this.unlinkPromise(lockPath);
continue;
} catch (error2) {
}
}
if (Date.now() - startTime < 60 * 1e3) {
await new Promise((resolve) => setTimeout(resolve, interval));
} else {
throw new Error(`Couldn't acquire a lock in a reasonable time (via ${lockPath})`);
}
} else {
throw error;
}
}
}
await this.writePromise(fd, JSON.stringify([process.pid]));
try {
return await callback();
} finally {
try {
await this.closePromise(fd);
await this.unlinkPromise(lockPath);
} catch (error) {
}
}
}
async readJsonPromise(p) {
const content = await this.readFilePromise(p, `utf8`);
try {
return JSON.parse(content);
} catch (error) {
error.message += ` (in ${p})`;
throw error;
}
}
readJsonSync(p) {
const content = this.readFileSync(p, `utf8`);
try {
return JSON.parse(content);
} catch (error) {
error.message += ` (in ${p})`;
throw error;
}
}
async writeJsonPromise(p, data, { compact = false } = {}) {
const space = compact ? 0 : 2;
return await this.writeFilePromise(p, `${JSON.stringify(data, null, space)}
`);
}
writeJsonSync(p, data, { compact = false } = {}) {
const space = compact ? 0 : 2;
return this.writeFileSync(p, `${JSON.stringify(data, null, space)}
`);
}
async preserveTimePromise(p, cb) {
const stat = await this.lstatPromise(p);
const result = await cb();
if (typeof result !== `undefined`)
p = result;
await this.lutimesPromise(p, stat.atime, stat.mtime);
}
async preserveTimeSync(p, cb) {
const stat = this.lstatSync(p);
const result = cb();
if (typeof result !== `undefined`)
p = result;
this.lutimesSync(p, stat.atime, stat.mtime);
}
}
class BasePortableFakeFS extends FakeFS {
constructor() {
super(ppath);
}
}
function getEndOfLine(content) {
const matches = content.match(/\r?\n/g);
if (matches === null)
return EOL;
const crlf = matches.filter((nl) => nl === `\r
`).length;
const lf = matches.length - crlf;
return crlf > lf ? `\r
` : `
`;
}
function normalizeLineEndings(originalContent, newContent) {
return newContent.replace(/\r?\n/g, getEndOfLine(originalContent));
}
class ProxiedFS extends FakeFS {
getExtractHint(hints) {
return this.baseFs.getExtractHint(hints);
}
resolve(path) {
return this.mapFromBase(this.baseFs.resolve(this.mapToBase(path)));
}
getRealPath() {
return this.mapFromBase(this.baseFs.getRealPath());
}
async openPromise(p, flags, mode) {
return this.baseFs.openPromise(this.mapToBase(p), flags, mode);
}
openSync(p, flags, mode) {
return this.baseFs.openSync(this.mapToBase(p), flags, mode);
}
async opendirPromise(p, opts) {
return Object.assign(await this.baseFs.opendirPromise(this.mapToBase(p), opts), { path: p });
}
opendirSync(p, opts) {
return Object.assign(this.baseFs.opendirSync(this.mapToBase(p), opts), { path: p });
}
async readPromise(fd, buffer, offset, length, position) {
return await this.baseFs.readPromise(fd, buffer, offset, length, position);
}
readSync(fd, buffer, offset, length, position) {
return this.baseFs.readSync(fd, buffer, offset, length, position);
}
async writePromise(fd, buffer, offset, length, position) {
if (typeof buffer === `string`) {
return await this.baseFs.writePromise(fd, buffer, offset);
} else {
return await this.baseFs.writePromise(fd, buffer, offset, length, position);
}
}
writeSync(fd, buffer, offset, length, position) {
if (typeof buffer === `string`) {
return this.baseFs.writeSync(fd, buffer, offset);
} else {
return this.baseFs.writeSync(fd, buffer, offset, length, position);
}
}
async closePromise(fd) {
return this.baseFs.closePromise(fd);
}
closeSync(fd) {
this.baseFs.closeSync(fd);
}
createReadStream(p, opts) {
return this.baseFs.createReadStream(p !== null ? this.mapToBase(p) : p, opts);
}
createWriteStream(p, opts) {
return this.baseFs.createWriteStream(p !== null ? this.mapToBase(p) : p, opts);
}
async realpathPromise(p) {
return this.mapFromBase(await this.baseFs.realpathPromise(this.mapToBase(p)));
}
realpathSync(p) {
return this.mapFromBase(this.baseFs.realpathSync(this.mapToBase(p)));
}
async existsPromise(p) {
return this.baseFs.existsPromise(this.mapToBase(p));
}
existsSync(p) {
return this.baseFs.existsSync(this.mapToBase(p));
}
accessSync(p, mode) {
return this.baseFs.accessSync(this.mapToBase(p), mode);
}
async accessPromise(p, mode) {
return this.baseFs.accessPromise(this.mapToBase(p), mode);
}
async statPromise(p, opts) {
return this.baseFs.statPromise(this.mapToBase(p), opts);
}
statSync(p, opts) {
return this.baseFs.statSync(this.mapToBase(p), opts);
}
async fstatPromise(fd, opts) {
return this.baseFs.fstatPromise(fd, opts);
}
fstatSync(fd, opts) {
return this.baseFs.fstatSync(fd, opts);
}
lstatPromise(p, opts) {
return this.baseFs.lstatPromise(this.mapToBase(p), opts);
}
lstatSync(p, opts) {
return this.baseFs.lstatSync(this.mapToBase(p), opts);
}
async fchmodPromise(fd, mask) {
return this.baseFs.fchmodPromise(fd, mask);
}
fchmodSync(fd, mask) {
return this.baseFs.fchmodSync(fd, mask);
}
async chmodPromise(p, mask) {
return this.baseFs.chmodPromise(this.mapToBase(p), mask);
}
chmodSync(p, mask) {
return this.baseFs.chmodSync(this.mapToBase(p), mask);
}
async fchownPromise(fd, uid, gid) {
return this.baseFs.fchownPromise(fd, uid, gid);
}
fchownSync(fd, uid, gid) {
return this.baseFs.fchownSync(fd, uid, gid);
}
async chownPromise(p, uid, gid) {
return this.baseFs.chownPromise(this.mapToBase(p), uid, gid);
}
chownSync(p, uid, gid) {
return this.baseFs.chownSync(this.mapToBase(p), uid, gid);
}
async renamePromise(oldP, newP) {
return this.baseFs.renamePromise(this.mapToBase(oldP), this.mapToBase(newP));
}
renameSync(oldP, newP) {
return this.baseFs.renameSync(this.mapToBase(oldP), this.mapToBase(newP));
}
async copyFilePromise(sourceP, destP, flags = 0) {
return this.baseFs.copyFilePromise(this.mapToBase(sourceP), this.mapToBase(destP), flags);
}
copyFileSync(sourceP, destP, flags = 0) {
return this.baseFs.copyFileSync(this.mapToBase(sourceP), this.mapToBase(destP), flags);
}
async appendFilePromise(p, content, opts) {
return this.baseFs.appendFilePromise(this.fsMapToBase(p), content, opts);
}
appendFileSync(p, content, opts) {
return this.baseFs.appendFileSync(this.fsMapToBase(p), content, opts);
}
async writeFilePromise(p, content, opts) {
return this.baseFs.writeFilePromise(this.fsMapToBase(p), content, opts);
}
writeFileSync(p, content, opts) {
return this.baseFs.writeFileSync(this.fsMapToBase(p), content, opts);
}
async unlinkPromise(p) {
return this.baseFs.unlinkPromise(this.mapToBase(p));
}
unlinkSync(p) {
return this.baseFs.unlinkSync(this.mapToBase(p));
}
async utimesPromise(p, atime, mtime) {
return this.baseFs.utimesPromise(this.mapToBase(p), atime, mtime);
}
utimesSync(p, atime, mtime) {
return this.baseFs.utimesSync(this.mapToBase(p), atime, mtime);
}
async lutimesPromise(p, atime, mtime) {
return this.baseFs.lutimesPromise(this.mapToBase(p), atime, mtime);
}
lutimesSync(p, atime, mtime) {
return this.baseFs.lutimesSync(this.mapToBase(p), atime, mtime);
}
async mkdirPromise(p, opts) {
return this.baseFs.mkdirPromise(this.mapToBase(p), opts);
}
mkdirSync(p, opts) {
return this.baseFs.mkdirSync(this.mapToBase(p), opts);
}
async rmdirPromise(p, opts) {
return this.baseFs.rmdirPromise(this.mapToBase(p), opts);
}
rmdirSync(p, opts) {
return this.baseFs.rmdirSync(this.mapToBase(p), opts);
}
async linkPromise(existingP, newP) {
return this.baseFs.linkPromise(this.mapToBase(existingP), this.mapToBase(newP));
}
linkSync(existingP, newP) {
return this.baseFs.linkSync(this.mapToBase(existingP), this.mapToBase(newP));
}
async symlinkPromise(target, p, type) {
const mappedP = this.mapToBase(p);
if (this.pathUtils.isAbsolute(target))
return this.baseFs.symlinkPromise(this.mapToBase(target), mappedP, type);
const mappedAbsoluteTarget = this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(p), target));
const mappedTarget = this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(mappedP), mappedAbsoluteTarget);
return this.baseFs.symlinkPromise(mappedTarget, mappedP, type);
}
symlinkSync(target, p, type) {
const mappedP = this.mapToBase(p);
if (this.pathUtils.isAbsolute(target))
return this.baseFs.symlinkSync(this.mapToBase(target), mappedP, type);
const mappedAbsoluteTarget = this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(p), target));
const mappedTarget = this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(mappedP), mappedAbsoluteTarget);
return this.baseFs.symlinkSync(mappedTarget, mappedP, type);
}
async readFilePromise(p, encoding) {
return this.baseFs.readFilePromise(this.fsMapToBase(p), encoding);
}
readFileSync(p, encoding) {
return this.baseFs.readFileSync(this.fsMapToBase(p), encoding);
}
readdirPromise(p, opts) {
return this.baseFs.readdirPromise(this.mapToBase(p), opts);
}
readdirSync(p, opts) {
return this.baseFs.readdirSync(this.mapToBase(p), opts);
}
async readlinkPromise(p) {
return this.mapFromBase(await this.baseFs.readlinkPromise(this.mapToBase(p)));
}
readlinkSync(p) {
return this.mapFromBase(this.baseFs.readlinkSync(this.mapToBase(p)));
}
async truncatePromise(p, len) {
return this.baseFs.truncatePromise(this.mapToBase(p), len);
}
truncateSync(p, len) {
return this.baseFs.truncateSync(this.mapToBase(p), len);
}
async ftruncatePromise(fd, len) {
return this.baseFs.ftruncatePromise(fd, len);
}
ftruncateSync(fd, len) {
return this.baseFs.ftruncateSync(fd, len);
}
watch(p, a, b) {
return this.baseFs.watch(
this.mapToBase(p),
a,
b
);
}
watchFile(p, a, b) {
return this.baseFs.watchFile(
this.mapToBase(p),
a,
b
);
}
unwatchFile(p, cb) {
return this.baseFs.unwatchFile(this.mapToBase(p), cb);
}
fsMapToBase(p) {
if (typeof p === `number`) {
return p;
} else {
return this.mapToBase(p);
}
}
}
function direntToPortable(dirent) {
const portableDirent = dirent;
if (typeof dirent.path === `string`)
portableDirent.path = npath.toPortablePath(dirent.path);
return portableDirent;
}
class NodeFS extends BasePortableFakeFS {
constructor(realFs = fs) {
super();
this.realFs = realFs;
}
getExtractHint() {
return false;
}
getRealPath() {
return PortablePath.root;
}
resolve(p) {
return ppath.resolve(p);
}
async openPromise(p, flags, mode) {
return await new Promise((resolve, reject) => {
this.realFs.open(npath.fromPortablePath(p), flags, mode, this.makeCallback(resolve, reject));
});
}
openSync(p, flags, mode) {
return this.realFs.openSync(npath.fromPortablePath(p), flags, mode);
}
async opendirPromise(p, opts) {
return await new Promise((resolve, reject) => {
if (typeof opts !== `undefined`) {
this.realFs.opendir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject));
} else {
this.realFs.opendir(npath.fromPortablePath(p), this.makeCallback(resolve, reject));
}
}).then((dir) => {
const dirWithFixedPath = dir;
Object.defineProperty(dirWithFixedPath, `path`, {
value: p,
configurable: true,
writable: true
});
return dirWithFixedPath;
});
}
opendirSync(p, opts) {
const dir = typeof opts !== `undefined` ? this.realFs.opendirSync(npath.fromPortablePath(p), opts) : this.realFs.opendirSync(npath.fromPortablePath(p));
const dirWithFixedPath = dir;
Object.defineProperty(dirWithFixedPath, `path`, {
value: p,
configurable: true,
writable: true
});
return dirWithFixedPath;
}
async readPromise(fd, buffer, offset = 0, length = 0, position = -1) {
return await new Promise((resolve, reject) => {
this.realFs.read(fd, buffer, offset, length, position, (error, bytesRead) => {
if (error) {
reject(error);
} else {
resolve(bytesRead);
}
});
});
}
readSync(fd, buffer, offset, length, position) {
return this.realFs.readSync(fd, buffer, offset, length, position);
}
async writePromise(fd, buffer, offset, length, position) {
return await new Promise((resolve, reject) => {
if (typeof buffer === `string`) {
return this.realFs.write(fd, buffer, offset, this.makeCallback(resolve, reject));
} else {
return this.realFs.write(fd, buffer, offset, length, position, this.makeCallback(resolve, reject));
}
});
}
writeSync(fd, buffer, offset, length, position) {
if (typeof buffer === `string`) {
return this.realFs.writeSync(fd, buffer, offset);
} else {
return this.realFs.writeSync(fd, buffer, offset, length, position);
}
}
async closePromise(fd) {
await new Promise((resolve, reject) => {
this.realFs.close(fd, this.makeCallback(resolve, reject));
});
}
closeSync(fd) {
this.realFs.closeSync(fd);
}
createReadStream(p, opts) {
const realPath = p !== null ? npath.fromPortablePath(p) : p;
return this.realFs.createReadStream(realPath, opts);
}
createWriteStream(p, opts) {
const realPath = p !== null ? npath.fromPortablePath(p) : p;
return this.realFs.createWriteStream(realPath, opts);
}
async realpathPromise(p) {
return await new Promise((resolve, reject) => {
this.realFs.realpath(npath.fromPortablePath(p), {}, this.makeCallback(resolve, reject));
}).then((path) => {
return npath.toPortablePath(path);
});
}
realpathSync(p) {
return npath.toPortablePath(this.realFs.realpathSync(npath.fromPortablePath(p), {}));
}
async existsPromise(p) {
return await new Promise((resolve) => {
this.realFs.exists(npath.fromPortablePath(p), resolve);
});
}
accessSync(p, mode) {
return this.realFs.accessSync(npath.fromPortablePath(p), mode);
}
async accessPromise(p, mode) {
return await new Promise((resolve, reject) => {
this.realFs.access(npath.fromPortablePath(p), mode, this.makeCallback(resolve, reject));
});
}
existsSync(p) {
return this.realFs.existsSync(npath.fromPortablePath(p));
}
async statPromise(p, opts) {
return await new Promise((resolve, reject) => {
if (opts) {
this.realFs.stat(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject));
} else {
this.realFs.stat(npath.fromPortablePath(p), this.makeCallback(resolve, reject));
}
});
}
statSync(p, opts) {
if (opts) {
return this.realFs.statSync(npath.fromPortablePath(p), opts);
} else {
return this.realFs.statSync(npath.fromPortablePath(p));
}
}
async fstatPromise(fd, opts) {
return await new Promise((resolve, reject) => {
if (opts) {
this.realFs.fstat(fd, opts, this.makeCallback(resolve, reject));
} else {
this.realFs.fstat(fd, this.makeCallback(resolve, reject));
}
});
}
fstatSync(fd, opts) {
if (opts) {
return this.realFs.fstatSync(fd, opts);
} else {
return this.realFs.fstatSync(fd);
}
}
async lstatPromise(p, opts) {
return await new Promise((resolve, reject) => {
if (opts) {
this.realFs.lstat(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject));
} else {
this.realFs.lstat(npath.fromPortablePath(p), this.makeCallback(resolve, reject));
}
});
}
lstatSync(p, opts) {
if (opts) {
return this.realFs.lstatSync(npath.fromPortablePath(p), opts);
} else {
return this.realFs.lstatSync(npath.fromPortablePath(p));
}
}
async fchmodPromise(fd, mask) {
return await new Promise((resolve, reject) => {
this.realFs.fchmod(fd, mask, this.makeCallback(resolve, reject));
});
}
fchmodSync(fd, mask) {
return this.realFs.fchmodSync(fd, mask);
}
async chmodPromise(p, mask) {
return await new Promise((resolve, reject) => {
this.realFs.chmod(npath.fromPortablePath(p), mask, this.makeCallback(resolve, reject));
});
}
chmodSync(p, mask) {
return this.realFs.chmodSync(npath.fromPortablePath(p), mask);
}
async fchownPromise(fd, uid, gid) {
return await new Promise((resolve, reject) => {
this.realFs.fchown(fd, uid, gid, this.makeCallback(resolve, reject));
});
}
fchownSync(fd, uid, gid) {
return this.realFs.fchownSync(fd, uid, gid);
}
async chownPromise(p, uid, gid) {
return await new Promise((resolve, reject) => {
this.realFs.chown(npath.fromPortablePath(p), uid, gid, this.makeCallback(resolve, reject));
});
}
chownSync(p, uid, gid) {
return this.realFs.chownSync(npath.fromPortablePath(p), uid, gid);
}
async renamePromise(oldP, newP) {
return await new Promise((resolve, reject) => {
this.realFs.rename(npath.fromPortablePath(oldP), npath.fromPortablePath(newP), this.makeCallback(resolve, reject));
});
}
renameSync(oldP, newP) {
return this.realFs.renameSync(npath.fromPortablePath(oldP), npath.fromPortablePath(newP));
}
async copyFilePromise(sourceP, destP, flags = 0) {
return await new Promise((resolve, reject) => {
this.realFs.copyFile(npath.fromPortablePath(sourceP), npath.fromPortablePath(destP), flags, this.makeCallback(resolve, reject));
});
}
copyFileSync(sourceP, destP, flags = 0) {
return this.realFs.copyFileSync(npath.fromPortablePath(sourceP), npath.fromPortablePath(destP), flags);
}
async appendFilePromise(p, content, opts) {
return await new Promise((resolve, reject) => {
const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p;
if (opts) {
this.realFs.appendFile(fsNativePath, content, opts, this.makeCallback(resolve, reject));
} else {
this.realFs.appendFile(fsNativePath, content, this.makeCallback(resolve, reject));
}
});
}
appendFileSync(p, content, opts) {
const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p;
if (opts) {
this.realFs.appendFileSync(fsNativePath, content, opts);
} else {
this.realFs.appendFileSync(fsNativePath, content);
}
}
async writeFilePromise(p, content, opts) {
return await new Promise((resolve, reject) => {
const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p;
if (opts) {
this.realFs.writeFile(fsNativePath, content, opts, this.makeCallback(resolve, reject));
} else {
this.realFs.writeFile(fsNativePath, content, this.makeCallback(resolve, reject));
}
});
}
writeFileSync(p, content, opts) {
const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p;
if (opts) {
this.realFs.writeFileSync(fsNativePath, content, opts);
} else {
this.realFs.writeFileSync(fsNativePath, content);
}
}
async unlinkPromise(p) {
return await new Promise((resolve, reject) => {
this.realFs.unlink(npath.fromPortablePath(p), this.makeCallback(resolve, reject));
});
}
unlinkSync(p) {
return this.realFs.unlinkSync(npath.fromPortablePath(p));
}
async utimesPromise(p, atime, mtime) {
return await new Promise((resolve, reject) => {
this.realFs.utimes(npath.fromPortablePath(p), atime, mtime, this.makeCallback(resolve, reject));
});
}
utimesSync(p, atime, mtime) {
this.realFs.utimesSync(npath.fromPortablePath(p), atime, mtime);
}
async lutimesPromise(p, atime, mtime) {
return await new Promise((resolve, reject) => {
this.realFs.lutimes(npath.fromPortablePath(p), atime, mtime, this.makeCallback(resolve, reject));
});
}
lutimesSync(p, atime, mtime) {
this.realFs.lutimesSync(npath.fromPortablePath(p), atime, mtime);
}
async mkdirPromise(p, opts) {
return await new Promise((resolve, reject) => {
this.realFs.mkdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject));
});
}
mkdirSync(p, opts) {
return this.realFs.mkdirSync(npath.fromPortablePath(p), opts);
}
async rmdirPromise(p, opts) {
return await new Promise((resolve, reject) => {
if (opts) {
this.realFs.rmdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject));
} else {
this.realFs.rmdir(npath.fromPortablePath(p), this.makeCallback(resolve, reject));
}
});
}
rmdirSync(p, opts) {
return this.realFs.rmdirSync(npath.fromPortablePath(p), opts);
}
async linkPromise(existingP, newP) {
return await new Promise((resolve, reject) => {
this.realFs.link(npath.fromPortablePath(existingP), npath.fromPortablePath(newP), this.makeCallback(resolve, reject));
});
}
linkSync(existingP, newP) {
return this.realFs.linkSync(npath.fromPortablePath(existingP), npath.fromPortablePath(newP));
}
async symlinkPromise(target, p, type) {
return await new Promise((resolve, reject) => {
this.realFs.symlink(npath.fromPortablePath(target.replace(/\/+$/, ``)), npath.fromPortablePath(p), type, this.makeCallback(resolve, reject));
});
}
symlinkSync(target, p, type) {
return this.realFs.symlinkSync(npath.fromPortablePath(target.replace(/\/+$/, ``)), npath.fromPortablePath(p), type);
}
async readFilePromise(p, encoding) {
return await new Promise((resolve, reject) => {
const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p;
this.realFs.readFile(fsNativePath, encoding, this.makeCallback(resolve, reject));
});
}
readFileSync(p, encoding) {
const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p;
return this.realFs.readFileSync(fsNativePath, encoding);
}
async readdirPromise(p, opts) {
return await new Promise((resolve, reject) => {
if (opts) {
if (opts.recursive && process.platform === `win32`) {
if (opts.withFileTypes) {
this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback((results) => resolve(results.map(direntToPortable)), reject));
} else {
this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback((results) => resolve(results.map(npath.toPortablePath)), reject));
}
} else {
this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject));
}
} else {
this.realFs.readdir(npath.fromPortablePath(p), this.makeCallback(resolve, reject));
}
});
}
readdirSync(p, opts) {
if (opts) {
if (opts.recursive && process.platform === `win32`) {
if (opts.withFileTypes) {
return this.realFs.readdirSync(npath.fromPortablePath(p), opts).map(direntToPortable);
} else {
return this.realFs.readdirSync(npath.fromPortablePath(p), opts).map(npath.toPortablePath);
}
} else {
return this.realFs.readdirSync(npath.fromPortablePath(p), opts);
}
} else {
return this.realFs.readdirSync(npath.fromPortablePath(p));
}
}
async readlinkPromise(p) {
return await new Promise((resolve, reject) => {
this.realFs.readlink(npath.fromPortablePath(p), this.makeCallback(resolve, reject));
}).then((path) => {
return npath.toPortablePath(path);
});
}
readlinkSync(p) {
return npath.toPortablePath(this.realFs.readlinkSync(npath.fromPortablePath(p)));
}
async truncatePromise(p, len) {
return await new Promise((resolve, reject) => {
this.realFs.truncate(npath.fromPortablePath(p), len, this.makeCallback(resolve, reject));
});
}
truncateSync(p, len) {
return this.realFs.truncateSync(npath.fromPortablePath(p), len);
}
async ftruncatePromise(fd, len) {
return await new Promise((resolve, reject) => {
this.realFs.ftruncate(fd, len, this.makeCallback(resolve, reject));
});
}
ftruncateSync(fd, len) {
return this.realFs.ftruncateSync(fd, len);
}
watch(p, a, b) {
return this.realFs.watch(
npath.fromPortablePath(p),
a,
b
);
}
watchFile(p, a, b) {
return this.realFs.watchFile(
npath.fromPortablePath(p),
a,
b
);
}
unwatchFile(p, cb) {
return this.realFs.unwatchFile(npath.fromPortablePath(p), cb);
}
makeCallback(resolve, reject) {
return (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
};
}
}
const NUMBER_REGEXP = /^[0-9]+$/;
const VIRTUAL_REGEXP = /^(\/(?:[^/]+\/)*?(?:\$\$virtual|__virtual__))((?:\/((?:[^/]+-)?[a-f0-9]+)(?:\/([^/]+))?)?((?:\/.*)?))$/;
const VALID_COMPONENT = /^([^/]+-)?[a-f0-9]+$/;
class VirtualFS extends ProxiedFS {
constructor({ baseFs = new NodeFS() } = {}) {
super(ppath);
this.baseFs = baseFs;
}
static makeVirtualPath(base, component, to) {
if (ppath.basename(base) !== `__virtual__`)
throw new Error(`Assertion failed: Virtual folders must be named "__virtual__"`);
if (!ppath.basename(component).match(VALID_COMPONENT))
throw new Error(`Assertion failed: Virtual components must be ended by an hexadecimal hash`);
const target = ppath.relative(ppath.dirname(base), to);
const segments = target.split(`/`);
let depth = 0;
while (depth < segments.length && segments[depth] === `..`)
depth += 1;
const finalSegments = segments.slice(depth);
const fullVirtualPath = ppath.join(base, component, String(depth), ...finalSegments);
return fullVirtualPath;
}
static resolveVirtual(p) {
const match = p.match(VIRTUAL_REGEXP);
if (!match || !match[3] && match[5])
return p;
const target = ppath.dirname(match[1]);
if (!match[3] || !match[4])
return target;
const isnum = NUMBER_REGEXP.test(match[4]);
if (!isnum)
return p;
const depth = Number(match[4]);
const backstep = `../`.repeat(depth);
const subpath = match[5] || `.`;
return VirtualFS.resolveVirtual(ppath.join(target, backstep, subpath));
}
getExtractHint(hints) {
return this.baseFs.getExtractHint(hints);
}
getRealPath() {
return this.baseFs.getRealPath();
}
realpathSync(p) {
const match = p.match(VIRTUAL_REGEXP);
if (!match)
return this.baseFs.realpathSync(p);
if (!match[5])
return p;
const realpath = this.baseFs.realpathSync(this.mapToBase(p));
return VirtualFS.makeVirtualPath(match[1], match[3], realpath);
}
async realpathPromise(p) {
const match = p.match(VIRTUAL_REGEXP);
if (!match)
return await this.baseFs.realpathPromise(p);
if (!match[5])
return p;
const realpath = await this.baseFs.realpathPromise(this.mapToBase(p));
return VirtualFS.makeVirtualPath(match[1], match[3], realpath);
}
mapToBase(p) {
if (p === ``)
return p;
if (this.pathUtils.isAbsolute(p))
return VirtualFS.resolveVirtual(p);
const resolvedRoot = VirtualFS.resolveVirtual(this.baseFs.resolve(PortablePath.dot));
const resolvedP = VirtualFS.resolveVirtual(this.baseFs.resolve(p));
return ppath.relative(resolvedRoot, resolvedP) || PortablePath.dot;
}
mapFromBase(p) {
return p;
}
}
const URL = Number(process.versions.node.split('.', 1)[0]) < 20 ? URL$1 : globalThis.URL;
const [major, minor] = process.versions.node.split(`.`).map((value) => parseInt(value, 10));
const WATCH_MODE_MESSAGE_USES_ARRAYS = major > 19 || major === 19 && minor >= 2 || major === 18 && minor >= 13;
const HAS_LAZY_LOADED_TRANSLATORS = major === 20 && minor < 6 || major === 19 && minor >= 3;
function readPackageScope(checkPath) {
const rootSeparatorIndex = checkPath.indexOf(npath.sep);
let separatorIndex;
do {
separatorIndex = checkPath.lastIndexOf(npath.sep);
checkPath = checkPath.slice(0, separatorIndex);
if (checkPath.endsWith(`${npath.sep}node_modules`))
return false;
const pjson = readPackage(checkPath + npath.sep);
if (pjson) {
return {
data: pjson,
path: checkPath
};
}
} while (separatorIndex > rootSeparatorIndex);
return false;
}
function readPackage(requestPath) {
const jsonPath = npath.resolve(requestPath, `package.json`);
if (!fs.existsSync(jsonPath))
return null;
return JSON.parse(fs.readFileSync(jsonPath, `utf8`));
}
async function tryReadFile$1(path2) {
try {
return await fs.promises.readFile(path2, `utf8`);
} catch (error) {
if (error.code === `ENOENT`)
return null;
throw error;
}
}
function tryParseURL(str, base) {
try {
return new URL(str, base);
} catch {
return null;
}
}
let entrypointPath = null;
function setEntrypointPath(file) {
entrypointPath = file;
}
function getFileFormat(filepath) {
const ext = path.extname(filepath);
switch (ext) {
case `.mjs`: {
return `module`;
}
case `.cjs`: {
return `commonjs`;
}
case `.wasm`: {
throw new Error(
`Unknown file extension ".wasm" for ${filepath}`
);
}
case `.json`: {
return `json`;
}
case `.js`: {
const pkg = readPackageScope(filepath);
if (!pkg)
return `commonjs`;
return pkg.data.type ?? `commonjs`;
}
default: {
if (entrypointPath !== filepath)
return null;
const pkg = readPackageScope(filepath);
if (!pkg)
return `commonjs`;
if (pkg.data.type === `module`)
return null;
return pkg.data.type ?? `commonjs`;
}
}
}
async function load$1(urlString, context, nextLoad) {
const url = tryParseURL(urlString);
if (url?.protocol !== `file:`)
return nextLoad(urlString, context, nextLoad);
const filePath = fileURLToPath(url);
const format = getFileFormat(filePath);
if (!format)
return nextLoad(urlString, context, nextLoad);
if (format === `json` && context.importAssertions?.type !== `json`) {
const err = new TypeError(`[ERR_IMPORT_ASSERTION_TYPE_MISSING]: Module "${urlString}" needs an import assertion of type "json"`);
err.code = `ERR_IMPORT_ASSERTION_TYPE_MISSING`;
throw err;
}
if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) {
const pathToSend = pathToFileURL(
npath.fromPortablePath(
VirtualFS.resolveVirtual(npath.toPortablePath(filePath))
)
).href;
process.send({
"watch:import": WATCH_MODE_MESSAGE_USES_ARRAYS ? [pathToSend] : pathToSend
});
}
return {
format,
source: format === `commonjs` ? void 0 : await fs.promises.readFile(filePath, `utf8`),
shortCircuit: true
};
}
const ArrayIsArray = Array.isArray;
const JSONStringify = JSON.stringify;
const ObjectGetOwnPropertyNames = Object.getOwnPropertyNames;
const ObjectPrototypeHasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
const RegExpPrototypeExec = (obj, string) => RegExp.prototype.exec.call(obj, string);
const RegExpPrototypeSymbolReplace = (obj, ...rest) => RegExp.prototype[Symbol.replace].apply(obj, rest);
const StringPrototypeEndsWith = (str, ...rest) => String.prototype.endsWith.apply(str, rest);
const StringPrototypeIncludes = (str, ...rest) => String.prototype.includes.apply(str, rest);
const StringPrototypeLastIndexOf = (str, ...rest) => String.prototype.lastIndexOf.apply(str, rest);
const StringPrototypeIndexOf = (str, ...rest) => String.prototype.indexOf.apply(str, rest);
const StringPrototypeReplace = (str, ...rest) => String.prototype.replace.apply(str, rest);
const StringPrototypeSlice = (str, ...rest) => String.prototype.slice.apply(str, rest);
const StringPrototypeStartsWith = (str, ...rest) => String.prototype.startsWith.apply(str, rest);
const SafeMap = Map;
const JSONParse = JSON.parse;
function createErrorType(code, messageCreator, errorType) {
return class extends errorType {
constructor(...args) {
super(messageCreator(...args));
this.code = code;
this.name = `${errorType.name} [${code}]`;
}
};
}
const ERR_PACKAGE_IMPORT_NOT_DEFINED = createErrorType(
`ERR_PACKAGE_IMPORT_NOT_DEFINED`,
(specifier, packagePath, base) => {
return `Package import specifier "${specifier}" is not defined${packagePath ? ` in package ${packagePath}package.json` : ``} imported from ${base}`;
},
TypeError
);
const ERR_INVALID_MODULE_SPECIFIER = createErrorType(
`ERR_INVALID_MODULE_SPECIFIER`,
(request, reason, base = void 0) => {
return `Invalid module "${request}" ${reason}${base ? ` imported from ${base}` : ``}`;
},
TypeError
);
const ERR_INVALID_PACKAGE_TARGET = createErrorType(
`ERR_INVALID_PACKAGE_TARGET`,
(pkgPath, key, target, isImport = false, base = void 0) => {
const relError = typeof target === `string` && !isImport && target.length && !StringPrototypeStartsWith(target, `./`);
if (key === `.`) {
assert(isImport === false);
return `Invalid "exports" main target ${JSONStringify(target)} defined in the package config ${pkgPath}package.json${base ? ` imported from ${base}` : ``}${relError ? `; targets must start with "./"` : ``}`;
}
return `Invalid "${isImport ? `imports` : `exports`}" target ${JSONStringify(
target
)} defined for '${key}' in the package config ${pkgPath}package.json${base ? ` imported from ${base}` : ``}${relError ? `; targets must start with "./"` : ``}`;
},
Error
);
const ERR_INVALID_PACKAGE_CONFIG = createErrorType(
`ERR_INVALID_PACKAGE_CONFIG`,
(path, base, message) => {
return `Invalid package config ${path}${base ? ` while importing ${base}` : ``}${message ? `. ${message}` : ``}`;
},
Error
);
function filterOwnProperties(source, keys) {
const filtered = /* @__PURE__ */ Object.create(null);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (ObjectPrototypeHasOwnProperty(source, key)) {
filtered[key] = source[key];
}
}
return filtered;
}
const packageJSONCache = new SafeMap();
function getPackageConfig(path, specifier, base, readFileSyncFn) {
const existing = packageJSONCache.get(path);
if (existing !== void 0) {
return existing;
}
const source = readFileSyncFn(path);
if (source === void 0) {
const packageConfig2 = {
pjsonPath: path,
exists: false,
main: void 0,
name: void 0,
type: "none",
exports: void 0,
imports: void 0
};
packageJSONCache.set(path, packageConfig2);
return packageConfig2;
}
let packageJSON;
try {
packageJSON = JSONParse(source);
} catch (error) {
throw new ERR_INVALID_PACKAGE_CONFIG(
path,
(base ? `"${specifier}" from ` : "") + fileURLToPath(base || specifier),
error.message
);
}
let { imports, main, name, type } = filterOwnProperties(packageJSON, [
"imports",
"main",
"name",
"type"
]);
const exports = ObjectPrototypeHasOwnProperty(packageJSON, "exports") ? packageJSON.exports : void 0;
if (typeof imports !== "object" || imports === null) {
imports = void 0;
}
if (typeof main !== "string") {
main = void 0;
}
if (typeof name !== "string") {
name = void 0;
}
if (type !== "module" && type !== "commonjs") {
type = "none";
}
const packageConfig = {
pjsonPath: path,
exists: true,
main,
name,
type,
exports,
imports
};
packageJSONCache.set(path, packageConfig);
return packageConfig;
}
function getPackageScopeConfig(resolved, readFileSyncFn) {
let packageJSONUrl = new URL("./package.json", resolved);
while (true) {
const packageJSONPath2 = packageJSONUrl.pathname;
if (StringPrototypeEndsWith(packageJSONPath2, "node_modules/package.json")) {
break;
}
const packageConfig2 = getPackageConfig(
fileURLToPath(packageJSONUrl),
resolved,
void 0,
readFileSyncFn
);
if (packageConfig2.exists) {
return packageConfig2;
}
const lastPackageJSONUrl = packageJSONUrl;
packageJSONUrl = new URL("../package.json", packageJSONUrl);
if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) {
break;
}
}
const packageJSONPath = fileURLToPath(packageJSONUrl);
const packageConfig = {
pjsonPath: packageJSONPath,
exists: false,
main: void 0,
name: void 0,
type: "none",
exports: void 0,
imports: void 0
};
packageJSONCache.set(packageJSONPath, packageConfig);
return packageConfig;
}
function throwImportNotDefined(specifier, packageJSONUrl, base) {
throw new ERR_PACKAGE_IMPORT_NOT_DEFINED(
specifier,
packageJSONUrl && fileURLToPath(new URL(".", packageJSONUrl)),
fileURLToPath(base)
);
}
function throwInvalidSubpath(subpath, packageJSONUrl, internal, base) {
const reason = `request is not a valid subpath for the "${internal ? "imports" : "exports"}" resolution of ${fileURLToPath(packageJSONUrl)}`;
throw new ERR_INVALID_MODULE_SPECIFIER(
subpath,
reason,
base && fileURLToPath(base)
);
}
function throwInvalidPackageTarget(subpath, target, packageJSONUrl, internal, base) {
if (typeof target === "object" && target !== null) {
target = JSONStringify(target, null, "");
} else {
target = `${target}`;
}
throw new ERR_INVALID_PACKAGE_TARGET(
fileURLToPath(new URL(".", packageJSONUrl)),
subpath,
target,
internal,
base && fileURLToPath(base)
);
}
const invalidSegmentRegEx = /(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))(\\|\/|$)/i;
const patternRegEx = /\*/g;
function resolvePackageTargetString(target, subpath, match, packageJSONUrl, base, pattern, internal, conditions) {
if (subpath !== "" && !pattern && target[target.length - 1] !== "/")
throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base);
if (!StringPrototypeStartsWith(target, "./")) {
if (internal && !StringPrototypeStartsWith(target, "../") && !StringPrototypeStartsWith(target, "/")) {
let isURL = false;
try {
new URL(target);
isURL = true;
} catch {
}
if (!isURL) {
const exportTarget = pattern ? RegExpPrototypeSymbolReplace(patternRegEx, target, () => subpath) : target + subpath;
return exportTarget;
}
}
throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base);
}
if (RegExpPrototypeExec(
invalidSegmentRegEx,
StringPrototypeSlice(target, 2)
) !== null)
throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base);
const resolved = new URL(target, packageJSONUrl);
const resolvedPath = resolved.pathname;
const packagePath = new URL(".", packageJSONUrl).pathname;
if (!StringPrototypeStartsWith(resolvedPath, packagePath))
throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base);
if (subpath === "")
return resolved;
if (RegExpPrototypeExec(invalidSegmentRegEx, subpath) !== null) {
const request = pattern ? StringPrototypeReplace(match, "*", () => subpath) : match + subpath;
throwInvalidSubpath(request, packageJSONUrl, internal, base);
}
if (pattern) {
return new URL(
RegExpPrototypeSymbolReplace(patternRegEx, resolved.href, () => subpath)
);
}
return new URL(subpath, resolved);
}
function isArrayIndex(key) {
const keyNum = +key;
if (`${keyNum}` !== key)
return false;
return keyNum >= 0 && keyNum < 4294967295;
}
function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, base, pattern, internal, conditions) {
if (typeof target === "string") {
return resolvePackageTargetString(
target,
subpath,
packageSubpath,
packageJSONUrl,
base,
pattern,
internal);
} else if (ArrayIsArray(target)) {
if (target.length === 0) {
return null;
}
let lastException;
for (let i = 0; i < target.length; i++) {
const targetItem = target[i];
let resolveResult;
try {
resolveResult = resolvePackageTarget(
packageJSONUrl,
targetItem,
subpath,
packageSubpath,
base,
pattern,
internal,
conditions
);
} catch (e) {
lastException = e;
if (e.code === "ERR_INVALID_PACKAGE_TARGET") {
continue;
}
throw e;
}
if (resolveResult === void 0) {
continue;
}
if (resolveResult === null) {
lastException = null;
continue;
}
return resolveResult;
}
if (lastException === void 0 || lastException === null)
return lastException;
throw lastException;
} else if (typeof target === "object" && target !== null) {
const keys = ObjectGetOwnPropertyNames(target);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (isArrayIndex(key)) {
throw new ERR_INVALID_PACKAGE_CONFIG(
fileURLToPath(packageJSONUrl),
base,
'"exports" cannot contain numeric property keys.'
);
}
}
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (key === "default" || conditions.has(key)) {
const conditionalTarget = target[key];
const resolveResult = resolvePackageTarget(
packageJSONUrl,
conditionalTarget,
subpath,
packageSubpath,
base,
pattern,
internal,
conditions
);
if (resolveResult === void 0)
continue;
return resolveResult;
}
}
return void 0;
} else if (target === null) {
return null;
}
throwInvalidPackageTarget(
packageSubpath,
target,
packageJSONUrl,
internal,
base
);
}
function patternKeyCompare(a, b) {
const aPatternIndex = StringPrototypeIndexOf(a, "*");
const bPatternIndex = StringPrototypeIndexOf(b, "*");
const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1;
const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1;
if (baseLenA > baseLenB)
return -1;
if (baseLenB > baseLenA)
return 1;
if (aPatternIndex === -1)
return 1;
if (bPatternIndex === -1)
return -1;
if (a.length > b.length)
return -1;
if (b.length > a.length)
return 1;
return 0;
}
function packageImportsResolve({ name, base, conditions, readFileSyncFn }) {
if (name === "#" || StringPrototypeStartsWith(name, "#/") || StringPrototypeEndsWith(name, "/")) {
const reason = "is not a valid internal imports specifier name";
throw new ERR_INVALID_MODULE_SPECIFIER(name, reason, fileURLToPath(base));
}
let packageJSONUrl;
const packageConfig = getPackageScopeConfig(base, readFileSyncFn);
if (packageConfig.exists) {
packageJSONUrl = pathToFileURL(packageConfig.pjsonPath);
const imports = packageConfig.imports;
if (imports) {
if (ObjectPrototypeHasOwnProperty(imports, name) && !StringPrototypeIncludes(name, "*")) {
const resolveResult = resolvePackageTarget(
packageJSONUrl,
imports[name],
"",
name,
base,
false,
true,
conditions
);
if (resolveResult != null) {
return resolveResult;
}
} else {
let bestMatch = "";
let bestMatchSubpath;
const keys = ObjectGetOwnPropertyNames(imports);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const patternIndex = StringPrototypeIndexOf(key, "*");
if (patternIndex !== -1 && StringPrototypeStartsWith(
name,
StringPrototypeSlice(key, 0, patternIndex)
)) {
const patternTrailer = StringPrototypeSlice(key, patternIndex + 1);
if (name.length >= key.length && StringPrototypeEndsWith(name, patternTrailer) && patternKeyCompare(bestMatch, key) === 1 && StringPrototypeLastIndexOf(key, "*") === patternIndex) {
bestMatch = key;
bestMatchSubpath = StringPrototypeSlice(
name,
patternIndex,
name.length - patternTrailer.length
);
}
}
}
if (bestMatch) {
const target = imports[bestMatch];
const resolveResult = resolvePackageTarget(
packageJSONUrl,
target,
bestMatchSubpath,
bestMatch,
base,
true,
true,
conditions
);
if (resolveResult != null) {
return resolveResult;
}
}
}
}
}
throwImportNotDefined(name, packageJSONUrl, base);
}
const pathRegExp = /^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/;
const isRelativeRegexp = /^\.{0,2}\//;
function tryReadFile(filePath) {
try {
return fs.readFileSync(filePath, `utf8`);
} catch (err) {
if (err.code === `ENOENT`)
return void 0;
throw err;
}
}
async function resolvePrivateRequest(specifier, issuer, context, nextResolve) {
const resolved = packageImportsResolve({
name: specifier,
base: pathToFileURL(issuer),
conditions: new Set(context.conditions),
readFileSyncFn: tryReadFile
});
if (resolved instanceof URL) {
return { url: resolved.href, shortCircuit: true };
} else {
if (resolved.startsWith(`#`))
throw new Error(`Mapping from one private import to another isn't allowed`);
return resolve$1(resolved, context, nextResolve);
}
}
async function resolve$1(originalSpecifier, context, nextResolve) {
const { findPnpApi } = moduleExports;
if (!findPnpApi || isBuiltin(originalSpecifier))
return nextResolve(originalSpecifier, context, nextResolve);
let specifier = originalSpecifier;
const url = tryParseURL(specifier, isRelativeRegexp.test(specifier) ? context.parentURL : void 0);
if (url) {
if (url.protocol !== `file:`)
return nextResolve(originalSpecifier, context, nextResolve);
specifier = fileURLToPath(url);
}
const { parentURL, conditions = [] } = context;
const issuer = parentURL && tryParseURL(parentURL)?.protocol === `file:` ? fileURLToPath(parentURL) : process.cwd();
const pnpapi = findPnpApi(issuer) ?? (url ? findPnpApi(specifier) : null);
if (!pnpapi)
return nextResolve(originalSpecifier, context, nextResolve);
if (specifier.startsWith(`#`))
return resolvePrivateRequest(specifier, issuer, context, nextResolve);
const dependencyNameMatch = specifier.match(pathRegExp);
let allowLegacyResolve = false;
if (dependencyNameMatch) {
const [, dependencyName, subPath] = dependencyNameMatch;
if (subPath === `` && dependencyName !== `pnpapi`) {
const resolved = pnpapi.resolveToUnqualified(`${dependencyName}/package.json`, issuer);
if (resolved) {
const content = await tryReadFile$1(resolved);
if (content) {
const pkg = JSON.parse(content);
allowLegacyResolve = pkg.exports == null;
}
}
}
}
let result;
try {
result = pnpapi.resolveRequest(specifier, issuer, {
conditions: new Set(conditions),
extensions: allowLegacyResolve ? void 0 : []
});
} catch (err) {
if (err instanceof Error && `code` in err && err.code === `MODULE_NOT_FOUND`)
err.code = `ERR_MODULE_NOT_FOUND`;
throw err;
}
if (!result)
throw new Error(`Resolving '${specifier}' from '${issuer}' failed`);
const resultURL = pathToFileURL(result);
if (url) {
resultURL.search = url.search;
resultURL.hash = url.hash;
}
if (!parentURL)
setEntrypointPath(fileURLToPath(resultURL));
return {
url: resultURL.href,
shortCircuit: true
};
}
if (!HAS_LAZY_LOADED_TRANSLATORS) {
const binding = process.binding(`fs`);
const originalReadFile = binding.readFileUtf8 || binding.readFileSync;
if (originalReadFile) {
binding[originalReadFile.name] = function(...args) {
try {
return fs.readFileSync(args[0], {
encoding: `utf8`,
flag: args[1]
});
} catch {
}
return originalReadFile.apply(this, args);
};
} else {
const binding2 = process.binding(`fs`);
const originalfstat = binding2.fstat;
const ZIP_MASK = 4278190080;
const ZIP_MAGIC = 704643072;
binding2.fstat = function(...args) {
const [fd, useBigint, req] = args;
if ((fd & ZIP_MASK) === ZIP_MAGIC && useBigint === false && req === void 0) {
try {
const stats = fs.fstatSync(fd);
return new Float64Array([
stats.dev,
stats.mode,
stats.nlink,
stats.uid,
stats.gid,
stats.rdev,
stats.blksize,
stats.ino,
stats.size,
stats.blocks
]);
} catch {
}
}
return originalfstat.apply(this, args);
};
}
}
const resolve = resolve$1;
const load = load$1;
export { load, resolve };
// next/components/ImageGallery.js // next/components/ImageGallery.js
import { useState } from 'react'; import { useState, useEffect } from 'react'; // Added useEffect
import Image from 'next/image'; import Image from 'next/image';
import Masonry from 'react-masonry-css'; import Masonry from 'react-masonry-css';
import styles from '@/styles/parvagues.module.css'; // Import css modules
export default function ImageGallery({ images, slug }) { export default function ImageGallery({ images, slug }) {
const [selectedImage, setSelectedImage] = useState(null); 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 = { const breakpointColumns = {
default: 3, default: 4, // Changed to 4 for a denser grid
1024: 3,
768: 2, 768: 2,
480: 1 480: 1,
}; };
const isPng = (src) => typeof src === 'string' && src.toLowerCase().endsWith('.png');
return ( return (
<> <>
<div className="mt-8"> <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 <Masonry
breakpointCols={breakpointColumns} breakpointCols={breakpointColumns}
className="masonry-grid" // Ensure this class or its child provides relative positioning for 'fill' className={styles.galleryGrid} // Use CSS module for masonry grid
columnClassName="masonry-grid_column" columnClassName={styles.galleryGridColumn}
> >
{images.map((imageSrc, i) => ( {images.map((imageSrc, i) => (
<div <div
key={i} key={i}
className="mb-4 cursor-pointer hover:opacity-75 transition-opacity" className={`${styles.galleryCard} mb-4 cursor-pointer group`}
onClick={() => setSelectedImage(imageSrc)} onClick={() => setSelectedImage(imageSrc)}
> >
{/* Ensure this div is the relatively positioned parent for fill */} <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'}`}>
<div className="relative aspect-square rounded-lg overflow-hidden"> {/* Tailwind's aspect-square utility */}
<Image <Image
src={imageSrc} src={imageSrc}
alt={`${slug} image ${i + 1}`} alt={`${slug} image ${i + 1}`}
fill 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>
</div> </div>
))} ))}
</Masonry> </Masonry>
</div> </div>
{/* Lightbox */} {/* Modal */}
{selectedImage && ( {selectedImage && (
<div <div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center cursor-zoom-out" className={styles.modalOverlay}
onClick={() => setSelectedImage(null)} 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 <Image
src={selectedImage} src={selectedImage}
alt="Selected image" alt="Selected image"
width={1200} // These are for the lightbox, not the gallery thumbs width={1600}
height={800} // These define the max dimensions and aspect ratio for the lightbox image height={1200}
className="max-w-full max-h-full object-contain" // object-contain is good for lightbox className="max-w-full max-h-full object-contain rounded-lg"
/> />
<button <button
className="absolute top-4 right-4 text-white text-2xl hover:text-purple-400 transition-colors" className={styles.modalCloseButton}
onClick={(e) => { onClick={() => setSelectedImage(null)}
e.stopPropagation();
setSelectedImage(null);
}}
> >
× &times; {/* Using HTML entity for '×' for better rendering */}
</button> </button>
</div> </div>
</div> </div>
......
...@@ -18,67 +18,71 @@ export default function ParVaguesFooter() { ...@@ -18,67 +18,71 @@ export default function ParVaguesFooter() {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
return ( 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={styles.neonGradient}></div>
<div className="max-w-6xl mx-auto px-4 relative z-10"> <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 */} {/* About Column */}
<div> <div className="md:col-span-1"> {/* Explicit column span */}
<p className="text-gray-400 text-sm mb-4"> <p className="text-gray-400 text-sm mb-4">
Livecoding de musique libre<br /> Livecoding de musique libre<br />
Performances algorithmiques en direct. Performances algorithmiques en direct.
</p> </p>
<p className="text-xs text-gray-500">
&copy; {year} ParVagues. Tous droits réservés.
</p>
</div> </div>
{/* Social Column */} {/* Logo Column (New) */}
<div className="flex flex-col items-center md:items-end"> <div className="md:col-span-1 flex flex-col items-center justify-center"> {/* This centers the block below */}
<div className="flex justify-center w-full"> <div className="relative mb-4 flex justify-center"> {/* This ensures the image within this block is centered */}
<div className="relative"> <Image
<Image src="/images/parvagues/logo.png"
src="/images/parvagues/logo.png" alt="ParVagues Logo"
alt="ParVagues Logo" width={100} // Reduced logo size
width={240} height={100} // Reduced logo size
height={240} />
className="mx-auto mb-4"
/>
</div>
</div> </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) => ( {socialLinks.map((link, index) => (
<a <a
key={index} key={index}
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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} aria-label={link.label}
> >
{link.icon} {React.cloneElement(link.icon, { size: '1.1em' })} {/* Slightly smaller icons */}
</a> </a>
))} ))}
</div> </div>
</div> </div>
</div> </div>
{/* Navigation Links - more compact */} {/* Navigation Links - more compact and centered */}
<div className="w-full bg-black py-4"> <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-8 text-xs tracking-widest uppercase flex-wrap"> <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-300 hover:text-[#ff3d7b] transition-colors tracking-wider px-3"> <Link href="/parvagues#music" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
MUSIQUE Musique
</Link> </Link>
<Link href="/parvagues#performances" className="text-gray-300 hover:text-[#ff3d7b] transition-colors tracking-wider px-3"> <Link href="/parvagues#performances" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
PERFORMANCES Performances
</Link> </Link>
<Link href="/parvagues#about" className="text-gray-300 hover:text-[#ff3d7b] transition-colors tracking-wider px-3"> <Link href="/parvagues#about" className="text-gray-400 hover:text-purple-400 transition-colors px-2">
À PROPOS À Propos
</Link> </Link>
<a <a
href="mailto:parvagues@nech.pl?subject=Booking Request" 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> </a>
</div> </div>
</div> </div>
...@@ -86,4 +90,4 @@ export default function ParVaguesFooter() { ...@@ -86,4 +90,4 @@ export default function ParVaguesFooter() {
</div> </div>
</footer> </footer>
); );
} }
\ No newline at end of file \ No newline at end of file
...@@ -27,66 +27,61 @@ function useScrolledPast(threshold = 100) { ...@@ -27,66 +27,61 @@ function useScrolledPast(threshold = 100) {
export default function ParVaguesHeader({ eventName = null, title = null }) { export default function ParVaguesHeader({ eventName = null, title = null }) {
const router = useRouter(); const router = useRouter();
const isHome = router.pathname === '/parvagues'; const isHome = router.pathname === '/parvagues';
const showInHeader = useScrolledPast(300); const showInHeader = useScrolledPast(300); // Threshold for showing title on scroll
const headerTitle = title || eventName || 'ParVagues'; const headerTitle = title || eventName || 'ParVagues';
return ( 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={`${styles.neonGradient} opacity-5 absolute inset-0`}></div>
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between"> <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 with Navigation */} {/* Logo and Title */}
<div className="flex items-center justify-between w-full"> <Link href="/parvagues" className="flex items-center group flex-shrink-0"> {/* Added flex-shrink-0 */}
<Link href="/parvagues" className="flex items-center group"> <div className="h-10 w-10 relative"> {/* Simplified logo div */}
<div className="h-10 w-10 relative flex-shrink-0"> <Image
<Image src="/images/parvagues/logo.png"
src="/images/parvagues/logo.png" alt="ParVagues Logo"
alt="ParVagues Logo" width={40}
width={48} height={40}
height={48} className="object-contain transition-all duration-300 group-hover:filter group-hover:drop-shadow-[0_0_8px_rgba(217,0,255,0.7)]"
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> <div className="overflow-hidden ml-3">
<span
<div className="overflow-hidden ml-2"> className={`text-white font-bold transition-all duration-500 whitespace-nowrap ${ /* Added whitespace-nowrap */
<span showInHeader || !isHome ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full'
className={`text-white font-bold transition-all duration-500 ${ }`}
showInHeader || !isHome ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-8' style={{
}`} textShadow: '0 0 5px rgba(217, 0, 255, 0.7), 0 0 10px rgba(217, 0, 255, 0.5)',
style={{ color: 'var(--neon-high)'
textShadow: '0 0 5px rgba(217, 0, 255, 0.7), 0 0 10px rgba(217, 0, 255, 0.5)', }}
color: 'var(--neon-high)'
}}
>
{headerTitle}
</span>
</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">
Music
</Link>
<Link href="/parvagues#performances" className="text-gray-300 hover:text-[#ff3d7b] transition-colors">
Performances
</Link>
<Link href="/parvagues#about" className="text-gray-300 hover:text-[#ff3d7b] transition-colors">
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`}
> >
<FaEnvelope className="mr-2 flex-shrink-0" /> {headerTitle}
<span>Book</span> </span>
</a>
</div> </div>
</div> </Link>
{/* Navigation Links */}
{/* 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={`${styles.navLink} text-gray-300 hover:text-[#ff3d7b] transition-colors px-2 py-1 sm:px-3`}>
Performances
</Link>
<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 */}
<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-1 sm:mr-2 h-3 w-3 sm:h-4 sm:w-4" /> {/* Responsive icon size */}
<span>Book</span>
</Link>
</div> </div>
</header> </header>
); );
......
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import { useDunbarStore } from '@/components/dunbar/useDunbarStore';
import dynamic from 'next/dynamic';
import { makeExportPayload } from '@/lib/dunbar';
import { generateDemoPayload } from '@/lib/dunbar-demo';
import { useRouter } from 'next/router';
import { friendSlug, eventSlug } from '@/lib/dunbar';
// Lazy-load heavy tabs if needed (Network uses d3)
const NetworkTab = dynamic(() => import('@/components/dunbar/NetworkTab'), { ssr: false });
const OrbitsTab = dynamic(() => import('@/components/dunbar/OrbitsTab'), { ssr: false });
const EventsTab = dynamic(() => import('@/components/dunbar/EventsTab'), { ssr: false });
// Lightweight components
import FriendsList from '@/components/dunbar/FriendsList';
import FriendDetail from '@/components/dunbar/FriendDetail';
import StatsTab from '@/components/dunbar/StatsTab';
import SearchTab from '@/components/dunbar/SearchTab';
const PASSWORD = 'freehugs4all';
function Tabs({ tab, setTab }) {
const items = [
{ id: 'friends', label: 'Friends' },
{ id: 'search', label: 'Search' },
{ id: 'events', label: 'Events' },
{ id: 'orbits', label: 'Orbits' },
{ id: 'network', label: 'Network' },
{ id: 'stats', label: 'Stats' },
];
return (
<div className={styles.tabs}>
{items.map((it) => (
<button
key={it.id}
className={`${styles.tabBtn} ${tab === it.id ? styles.tabActive : ''}`}
onClick={() => setTab(it.id)}
>
{it.label}
</button>
))}
</div>
);
}
export default function DunbarApp() {
const router = useRouter();
const { state, friends, selectedFriendId, actions, derived } = useDunbarStore();
const [tab, setTab] = useState('friends');
const [authed, setAuthed] = useState(false);
const [lockError, setLockError] = useState('');
const friendsListScrollRef = useRef(0);
const fileInputRef = useRef(null);
// Password gate: prompt once per session
// Dev convenience: auto-unlock on localhost or NODE_ENV=development to enable automated preview
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = window.sessionStorage.getItem('dunbarAuthed');
const isDev = process.env.NODE_ENV === 'development' || window.location.hostname === 'localhost';
if (stored === '1' || isDev) {
setAuthed(true);
return;
}
// show lock UI; user presses "Unlock" to prompt
}, []);
const handleUnlock = () => {
if (typeof window === 'undefined') return;
const ans = window.prompt('Enter password for Dunbar');
if (ans === PASSWORD) {
window.sessionStorage.setItem('dunbarAuthed', '1');
setAuthed(true);
setLockError('');
} else {
setLockError('Wrong password. Try again.');
}
};
const selectedFriend = useMemo(() => friends.find((f) => f.id === selectedFriendId) || null, [friends, selectedFriendId]);
// Export / Import
const onExport = () => {
try {
const payload = makeExportPayload(state);
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'dunbar-export.json';
a.click();
URL.revokeObjectURL(url);
} catch (e) {
// eslint-disable-next-line no-alert
alert('Export failed');
}
};
const onImportClick = () => fileInputRef.current?.click();
const onImportFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const json = JSON.parse(text);
actions.loadFromPayload(json);
} catch {
// eslint-disable-next-line no-alert
alert('Invalid import file');
} finally {
e.target.value = '';
}
};
const onDemo = () => {
if (typeof window !== 'undefined') {
const ok = window.confirm('Remplacer les données actuelles par une démo de 50 profils ?');
if (!ok) return;
}
const payload = generateDemoPayload(50);
actions.loadFromPayload(payload);
};
// Navigation from viz → friend detail
const openFriendDetail = (friendId) => {
actions.selectFriend(friendId);
setTab('friends');
const f = friends.find((x) => x.id === friendId);
if (f) {
router.push(`/dunbar/friend/${friendSlug(f)}`, undefined, { shallow: true });
}
};
const openEventDetail = (evLike) => {
// evLike may be from merged index or minimal shape
const id = evLike?.id;
const e = (derived.eventIndex || []).find((x) => x.id === id) || evLike;
if (!e) return;
setTab('events');
actions.selectEvent(e.id);
router.push(`/dunbar/event/${eventSlug(e)}`, undefined, { shallow: true });
};
// Deep-link handling: friend/event/search routes hydrate initial tab/selection
useEffect(() => {
if (!router || !router.asPath) return;
const as = router.asPath || '';
// friend route
const friendMatch = as.match(/\/dunbar\/friend\/([^/?#]+)/);
if (friendMatch) {
const slug = friendMatch[1];
// suffix-based lookup (last 6 chars of id)
const suff = slug.split('-').pop();
const f = friends.find((x) => String(x.id).endsWith(suff)) ||
friends.find((x) => friendSlug(x) === slug);
if (f) {
actions.selectFriend(f.id);
setTab('friends');
}
return;
}
// event route
const eventMatch = as.match(/\/dunbar\/event\/([^/?#]+)/);
if (eventMatch) {
const slug = eventMatch[1];
const suff = slug.split('-').pop();
const e = (derived.eventIndex || []).find((x) => String(x.id).endsWith(suff));
if (e) {
setTab('events');
actions.selectEvent(e.id);
}
return;
}
// search route
const searchMatch = as.match(/\/dunbar\/search/);
if (searchMatch) {
setTab('search');
return;
}
}, [router?.asPath, friends, derived.eventIndex, actions]);
if (!authed) {
return (
<div className={styles.lockWrap}>
<h2 className={styles.title}>Dunbar</h2>
<p>Privacy-first relationship navigator. Local-only storage.</p>
<button className={styles.btn} onClick={handleUnlock}>Unlock</button>
{lockError ? <div style={{ color: '#b91c1c', marginTop: 8 }}>{lockError}</div> : null}
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.row}>
<h1 className={styles.title}>Dunbar</h1>
<span className={styles.badge}>{friends.length} friends</span>
</div>
<div className={styles.toolbar}>
<button className={styles.btnSecondary} onClick={onExport}>Export</button>
<button className={styles.btnSecondary} onClick={onImportClick}>Import</button>
<input
ref={fileInputRef}
type="file"
accept="application/json"
onChange={onImportFile}
style={{ display: 'none' }}
/>
<button className={styles.btnSecondary} onClick={onDemo}>Demo</button>
<button className={styles.btnSecondary} onClick={actions.resetData}>Reset Data</button>
</div>
</div>
<Tabs tab={tab} setTab={setTab} />
{tab === 'friends' && (
<div className={styles.twoCol}>
<div>
<FriendsList
friends={friends}
selectedFriendId={selectedFriendId}
onSelect={(id) => actions.selectFriend(id)}
onAddFriend={(name) => actions.addFriend(name)}
onRemoveFriend={(id) => actions.removeFriend(id)}
onRename={(id, name) => actions.renameFriend(id, name)}
// preserve list scroll when opening/closing detail
onSaveScroll={(y) => (friendsListScrollRef.current = y)}
initialScroll={friendsListScrollRef.current}
getConnectionCount={(id) => derived ? null : null}
/>
</div>
<div>
<FriendDetail
friend={selectedFriend}
friends={friends}
onToggleRel={(a, b) => actions.toggleRelationship(a, b)}
onAddEvent={(payload) => actions.addEvent(payload)}
onUpdateEvent={(id, patch) => actions.updateEvent(id, patch)}
onRename={(id, name) => actions.renameFriend(id, name)}
onSetBirthday={(id, ymd) => actions.setBirthday(id, ymd)}
onSetNotes={(id, notes) => actions.setFriendNotes(id, notes)}
onUpdateFriend={(id, patch) => actions.updateFriend(id, patch)}
// scroll preservation inside relationship list handled internally
/>
</div>
</div>
)}
{tab === 'search' && (
<SearchTab
friends={friends}
openFriend={openFriendDetail}
openEvent={openEventDetail}
/>
)}
{tab === 'events' && (
<EventsTab
friends={friends}
addEvent={(payload) => actions.addEvent(payload)}
updateEvent={(id, patch) => actions.updateEvent(id, patch)}
selectedEventId={state.selectedEventId}
eventIndex={derived.eventIndex}
openEvent={openEventDetail}
/>
)}
{tab === 'orbits' && (
<OrbitsTab
friends={friends}
buckets={derived.orbitBuckets}
openFriendDetail={openFriendDetail}
/>
)}
{tab === 'network' && (
<NetworkTab
friends={friends}
toggleRel={(a, b) => actions.toggleRelationship(a, b)}
openFriendDetail={openFriendDetail}
/>
)}
{tab === 'stats' && (
<StatsTab
stats={derived.stats}
anniversaries={derived.anniversaries}
eventIndex={derived.eventIndex}
openFriend={openFriendDetail}
/>
)}
</div>
);
}
import { useMemo, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import {
todayISO,
yesterdayISO,
weekAgoISO,
startOfMonthISO,
groupEventsByDay,
isoDate,
} from '@/lib/dunbar';
import { extractTags } from '@/lib/dunbar';
import { detectLang, topKeywordsForDocs, extractTopics } from '@/lib/dunbar-nlp';
export default function EventsTab({ friends, addEvent, updateEvent, selectedEventId, eventIndex, openEvent }) {
// Creation form state
const [date, setDate] = useState(todayISO());
const [notes, setNotes] = useState('');
const [location, setLocation] = useState('');
const [title, setTitle] = useState('');
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState(() => new Set());
const friendMap = useMemo(() => {
const m = new Map();
for (const f of friends) m.set(f.id, f);
return m;
}, [friends]);
const filteredFriends = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return friends;
return friends.filter((f) => f.name.toLowerCase().includes(q));
}, [friends, filter]);
const toggleFriend = (id) => {
const s = new Set(selected);
if (s.has(id)) s.delete(id);
else s.add(id);
setSelected(s);
};
const selectedCount = selected.size;
const canCreate = selectedCount > 0 && notes.trim().length > 0 && title.trim().length > 0;
const createEvent = () => {
if (!canCreate) return;
const dateISO = new Date(date).toISOString();
addEvent({
date: dateISO,
title: title.trim(),
notes: notes.trim(),
location: location.trim() || undefined,
participants: Array.from(selected),
});
// reset minimal fields, keep filter/selection to ease batch creation
setNotes('');
};
// Timeline groups from merged eventIndex
const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]);
// NLP: keywords per event (TF-IDF over corpus) and language guess
const keywordData = useMemo(() => {
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''}`,
}));
const corpusText = docs.map((d) => d.text).join(' ');
const lang = detectLang(corpusText) || null;
const top = topKeywordsForDocs(docs, { lang, topK: 6 });
const byId = new Map(top.map((d) => [d.id, d.keywords]));
return { byId, lang };
}, [eventIndex]);
// Topics (beta) — computed on demand
const [topics, setTopics] = useState([]);
const [topicsLoading, setTopicsLoading] = useState(false);
const runTopics = async () => {
try {
setTopicsLoading(true);
const docs = (eventIndex || []).map((e) => ({
id: e.id,
text: `${e.title || ''} ${e.notes || ''}`,
}));
const res = await extractTopics(docs, { topics: 5, termsPerTopic: 6, lang: keywordData.lang || null });
setTopics(res);
} finally {
setTopicsLoading(false);
}
};
// Selected event editor state
const selectedEvent = useMemo(
() => (selectedEventId ? (eventIndex || []).find((e) => e.id === selectedEventId) : null),
[eventIndex, selectedEventId]
);
const [edit, setEdit] = useState(() => ({
id: null,
date: todayISO(),
title: '',
notes: '',
location: '',
participants: new Set(),
}));
// Hydrate editor when selected changes
useMemo(() => {
if (!selectedEvent) return edit;
const e = selectedEvent;
setEdit({
id: e.id,
date: e.date,
title: e.title || '',
notes: e.notes || '',
location: e.location || '',
participants: new Set(e.participants || []),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEventId]);
const toggleEditParticipant = (id) => {
setEdit((prev) => {
const p = new Set(prev.participants);
if (p.has(id)) p.delete(id);
else p.add(id);
return { ...prev, participants: p };
});
};
const canSaveEdit = !!edit.id && String(edit.title || '').trim() && String(edit.notes || '').trim();
const saveEdit = () => {
if (!canSaveEdit) return;
updateEvent?.(edit.id, {
date: edit.date,
title: edit.title.trim(),
notes: edit.notes.trim(),
location: edit.location.trim() || undefined,
participants: Array.from(edit.participants),
});
};
// 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}</>;
};
return (
<div className={styles.twoCol} style={{ gap: 16 }}>
{/* New Event Builder */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>New Event</span>
</div>
<div className={styles.row} style={{ gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<button className={styles.btnSecondary} onClick={() => setDate(todayISO())}>Today</button>
<button className={styles.btnSecondary} onClick={() => setDate(yesterdayISO())}>Yesterday</button>
<button className={styles.btnSecondary} onClick={() => setDate(weekAgoISO())}>Week Ago</button>
<button className={styles.btnSecondary} onClick={() => setDate(startOfMonthISO())}>Start of Month</button>
</div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<input
className={styles.input}
placeholder="Location (optional)"
value={location}
onChange={(e) => setLocation(e.target.value)}
style={{ minWidth: 160 }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes (required)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.card} style={{ marginTop: 8 }}>
<div className={styles.cardHeader}>
<span>Friends ({selectedCount} selected)</span>
<input
className={styles.input}
placeholder="Search friends…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={styles.scroll}>
{filteredFriends.map((f) => {
const checked = selected.has(f.id);
return (
<label key={f.id} className={styles.switchRow} style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleFriend(f.id)}
/>
<span style={{ fontWeight: 600, marginLeft: 8 }}>{f.name}</span>
</label>
);
})}
{filteredFriends.length === 0 && (
<div style={{ padding: 8, color: '#666' }}>No matches.</div>
)}
</div>
</div>
<div className={styles.row} style={{ marginTop: 12 }}>
<button className={styles.btn} onClick={createEvent} disabled={!canCreate}>
Create Event
</button>
</div>
</div>
{/* Timeline + Editor */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Timeline</span>
<button
className={styles.btnSecondary}
onClick={runTopics}
disabled={topicsLoading || !(eventIndex || []).length}
title="Compute topics from titles+notes (local)"
>
{topicsLoading ? 'Topics…' : 'Topics (beta)'}
</button>
</div>
{topics && topics.length > 0 ? (
<div className={styles.tagRow} style={{ margin: '8px 0' }}>
{topics.map((t, i) => (
<span key={i} className={styles.tagChip} title={t.terms.map(([term]) => term).join(', ')}>
Topic {i + 1}: {t.terms.slice(0, 3).map(([term]) => term).join(' / ')}
</span>
))}
</div>
) : null}
{/* Event Editor */}
{selectedEvent ? (
<div className={styles.card} style={{ marginBottom: 12 }}>
<div className={styles.cardHeader}><span>Edit Event</span></div>
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={edit.date}
onChange={(e) => setEdit({ ...edit, date: e.target.value })}
/>
<input
className={styles.input}
placeholder="Location (optional)"
value={edit.location}
onChange={(e) => setEdit({ ...edit, location: e.target.value })}
style={{ minWidth: 160 }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={edit.title}
onChange={(e) => setEdit({ ...edit, title: e.target.value })}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes (required)"
value={edit.notes}
onChange={(e) => setEdit({ ...edit, notes: e.target.value })}
style={{ width: '100%' }}
/>
</div>
<div className={styles.card} style={{ marginTop: 8 }}>
<div className={styles.cardHeader}>
<span>Participants ({edit.participants.size})</span>
<input
className={styles.input}
placeholder="Filter…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={styles.scroll}>
{filteredFriends.map((f) => {
const checked = edit.participants.has(f.id);
return (
<label key={f.id} className={styles.switchRow} style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleEditParticipant(f.id)}
/>
<span style={{ fontWeight: 600, marginLeft: 8 }}>{f.name}</span>
</label>
);
})}
</div>
</div>
<div className={styles.row} style={{ marginTop: 12 }}>
<button className={styles.btn} onClick={saveEdit} disabled={!canSaveEdit}>
Save Changes
</button>
</div>
</div>
) : null}
<div className={styles.timeline}>
{groups.map((g) => (
<div key={g.dateKey} className={styles.timelineGroup}>
<div className={styles.timelineDate}>{g.label}</div>
{g.items.map((e) => {
const names = (e.participants || [])
.map((id) => friendMap.get(id)?.name)
.filter(Boolean);
return (
<div key={e.id + e.date} className={styles.timelineEvent}>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => openEvent?.(e)}
title="Open event details"
>
{e.title || '(untitled)'}
</div>
<div className={styles.itemMeta}>{names.join(', ') || 'Unknown'}</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')}
</div>
{e.location ? (
<div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</div>
) : null}
{/* Tag chips */}
{(() => {
const tags = extractTags(e.notes || '');
return tags.length ? (
<div className={styles.tagRow}>
{tags.slice(0, 10).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null;
})()}
{(() => {
const kws = keywordData.byId.get(e.id) || [];
return kws.length ? (
<div className={styles.tagRow} style={{ marginTop: 4 }}>
{kws.slice(0, 6).map(([term]) => (
<span key={term} className={styles.tagChip}>{term}</span>
))}
</div>
) : null;
})()}
<div style={{ color: '#888', marginTop: 4, fontSize: 12 }}>
{isoDate(e.date)}
</div>
</div>
);
})}
</div>
))}
{groups.length === 0 && (
<div style={{ color: '#666' }}>No events yet create one on the left.</div>
)}
</div>
</div>
</div>
);
}
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import { isoDate, sortEventsDesc, extractTags, eventSlug } from '@/lib/dunbar';
export default function FriendDetail({
friend,
friends,
onToggleRel, // (aId, bId) => void
onAddEvent, // ({ date, notes, participants[], location? }) => void
onRename, // (id, name) => void
onSetBirthday, // (id, ymd) => void
onSetNotes, // (id, notes) => void
onUpdateFriend, // (id, patch) => void
}) {
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [notes, setNotes] = useState('');
const [location, setLocation] = useState('');
const [title, setTitle] = useState('');
const [friendNotes, setFriendNotes] = useState('');
const [birthday, setBirthday] = useState('');
const relScrollRef = useRef(null);
// Inline rename for friend's name
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState('');
const startEditName = () => {
if (!friend) return;
setEditing(true);
setEditName(friend.name || '');
};
const commitEditName = () => {
if (!friend) return;
const n = (editName || '').trim();
if (n) onRename?.(friend.id, n);
setEditing(false);
};
const cancelEditName = () => {
setEditing(false);
setEditName('');
};
// Sync friend notes and birthday when selection changes
useEffect(() => {
if (!friend) {
setFriendNotes('');
setBirthday('');
return;
}
setFriendNotes(friend.notes || '');
setBirthday(friend.birthday || '');
}, [friend]);
const saveFriendNotes = () => {
if (!friend) return;
onSetNotes?.(friend.id, friendNotes);
};
const saveBirthday = (val) => {
if (!friend) return;
const v = (val || '').slice(0, 10);
setBirthday(v);
onSetBirthday?.(friend.id, v);
};
// Rich profile helpers
const patch = (p) => {
if (!friend) return;
onUpdateFriend?.(friend.id, p);
};
// Important dates
const [idate, setIDate] = useState('');
const [ilabel, setILabel] = useState('');
const addImportantDate = () => {
if (!friend) return;
const d = (idate || '').slice(0, 10);
if (!d) return;
const arr = Array.isArray(friend.importantDates) ? [...friend.importantDates] : [];
arr.push({ date: d, label: ilabel || '' });
patch({ importantDates: arr });
setIDate('');
setILabel('');
};
const removeImportantDate = (idx) => {
if (!friend) return;
const arr = (friend.importantDates || []).filter((_, i) => i !== idx);
patch({ importantDates: arr });
};
// Gifts
const [g, setG] = useState({ date: '', occasion: '', description: '', image: '' });
const addGift = () => {
if (!friend) return;
const arr = Array.isArray(friend.gifts) ? [...friend.gifts] : [];
arr.push({
date: (g.date || '').slice(0, 10),
occasion: g.occasion || '',
description: g.description || '',
image: g.image || '',
});
patch({ gifts: arr });
setG({ date: '', occasion: '', description: '', image: '' });
};
const removeGift = (idx) => {
if (!friend) return;
const arr = (friend.gifts || []).filter((_, i) => i !== idx);
patch({ gifts: arr });
};
// Postcards
const [pc, setPC] = useState({ date: '', location: '', description: '', image: '' });
const addPostcard = () => {
if (!friend) return;
const arr = Array.isArray(friend.postcards) ? [...friend.postcards] : [];
arr.push({
date: (pc.date || '').slice(0, 10),
location: pc.location || '',
description: pc.description || '',
image: pc.image || '',
});
patch({ postcards: arr });
setPC({ date: '', location: '', description: '', image: '' });
};
const removePostcard = (idx) => {
if (!friend) return;
const arr = (friend.postcards || []).filter((_, i) => i !== idx);
patch({ postcards: arr });
};
// 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}</>;
};
// Scroll preservation for toggles: store + restore scrollTop across updates
const beforeToggle = useRef(0);
const restorePending = useRef(false);
useEffect(() => {
if (restorePending.current && relScrollRef.current) {
const st = beforeToggle.current;
// Restore next tick
const id = setTimeout(() => {
try {
relScrollRef.current.scrollTop = st;
} catch {}
restorePending.current = false;
}, 0);
return () => clearTimeout(id);
}
});
const others = useMemo(() => {
if (!friend) return [];
return friends.filter((f) => f.id !== friend.id);
}, [friend, friends]);
const friendRelSet = useMemo(() => {
return friend ? friend.relationships || new Set() : new Set();
}, [friend]);
const eventsDesc = useMemo(() => {
if (!friend) return [];
return sortEventsDesc(friend.events);
}, [friend]);
const onToggle = (targetId) => {
if (!friend) return;
if (relScrollRef.current) beforeToggle.current = relScrollRef.current.scrollTop;
restorePending.current = true;
onToggleRel?.(friend.id, targetId);
};
const submitEvent = () => {
if (!friend) return;
const dateISO = new Date(date).toISOString();
const n = notes.trim();
const t = title.trim();
if (!dateISO || !t || !n) return;
onAddEvent?.({
date: dateISO,
title: t,
notes: n,
location: location.trim() || undefined,
participants: [friend.id],
});
// reset notes/title only; keep date for faster entry
setNotes('');
setTitle('');
};
if (!friend) {
return (
<div className={styles.card}>
<div className={styles.cardHeader}>Friend details</div>
<div style={{ color: '#666' }}>Select a friend from the list to view and edit details.</div>
</div>
);
}
const evCount = Array.isArray(friend.events) ? friend.events.length : 0;
const connCount = friend.relationships ? friend.relationships.size : 0;
return (
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>
{editing ? (
<input
className={styles.input}
value={editName}
autoFocus
onChange={(e) => setEditName(e.target.value)}
onBlur={commitEditName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitEditName();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditName();
}
}}
style={{ maxWidth: 260 }}
/>
) : (
<span
title="Click to rename"
onClick={startEditName}
style={{ cursor: 'text', fontWeight: 600 }}
>
{friend.name}
</span>
)}
</span>
<span className={styles.badge}>{evCount} events · {connCount} connections</span>
</div>
<div className={styles.row} style={{ gap: 8, margin: '8px 0', flexWrap: 'wrap' }}>
<label style={{ fontSize: 12, color: '#555' }}>Anniv:</label>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={birthday || ''}
onChange={(e) => saveBirthday(e.target.value)}
/>
</div>
<div className={styles.twoCol} style={{ gap: 12 }}>
{/* Relationships */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Relationships ({others.length})</span>
</div>
<div ref={relScrollRef} className={styles.scroll}>
{others.map((o) => {
const onRel = friendRelSet.has(o.id);
return (
<div key={o.id} className={styles.switchRow}>
<div style={{ minWidth: 160, fontWeight: 600 }}>{o.name}</div>
<div
className={`${styles.switch} ${onRel ? styles.switchOn : ''}`}
onClick={() => onToggle(o.id)}
title={onRel ? 'Connected — click to remove' : 'Not connected — click to connect'}
style={{ cursor: 'pointer' }}
>
<div className={`${styles.knob} ${onRel ? styles.knobOn : ''}`} />
</div>
</div>
);
})}
{others.length === 0 && (
<div style={{ padding: 8, color: '#666' }}>No other friends to connect yet.</div>
)}
</div>
</div>
{/* Friend Notes */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Notes (ami)</span>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes libres sur l’ami·e (thèmes, centres d’intérêt, etc.)"
value={friendNotes}
onChange={(e) => setFriendNotes(e.target.value)}
onBlur={saveFriendNotes}
style={{ width: '100%' }}
/>
</div>
</div>
{/* Profil */}
<div className={styles.card}>
<div className={styles.cardHeader}><span>Profil</span></div>
{/* Likes/Dislikes */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input className={styles.input} placeholder="Aime"
value={friend?.likes || ''} onChange={(e) => patch({ likes: e.target.value })} style={{ minWidth: 200 }}/>
<input className={styles.input} placeholder="N'aime pas"
value={friend?.dislikes || ''} onChange={(e) => patch({ dislikes: e.target.value })} style={{ minWidth: 200 }}/>
</div>
{/* Food */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input className={styles.input} placeholder="Aime (nourriture/boissons)"
value={friend?.foodLikes || ''} onChange={(e) => patch({ foodLikes: e.target.value })} style={{ minWidth: 240 }}/>
<input className={styles.input} placeholder="N'aime pas (nourriture/boissons)"
value={friend?.foodDislikes || ''} onChange={(e) => patch({ foodDislikes: e.target.value })} style={{ minWidth: 240 }}/>
</div>
{/* General info */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input className={styles.input} placeholder="Mot de passe Wi‑Fi"
value={friend?.wifiPassword || ''} onChange={(e) => patch({ wifiPassword: e.target.value })}/>
<input className={styles.input} placeholder="Modèle de voiture"
value={friend?.carModel || ''} onChange={(e) => patch({ carModel: e.target.value })}/>
<input className={styles.input} placeholder="Lieu de travail"
value={friend?.workplace || ''} onChange={(e) => patch({ workplace: e.target.value })}/>
<input className={styles.input} placeholder="Emploi du temps"
value={friend?.schedule || ''} onChange={(e) => patch({ schedule: e.target.value })}/>
</div>
{/* Future ideas & quotes */}
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea className={styles.textarea} placeholder="Idées d'activités/sorties futures"
value={friend?.futureIdeas || ''} onChange={(e) => patch({ futureIdeas: e.target.value })} style={{ width: '100%' }}/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea className={styles.textarea} placeholder="Phrases cultes / blagues"
value={friend?.quotes || ''} onChange={(e) => patch({ quotes: e.target.value })} style={{ width: '100%' }}/>
</div>
{/* Important dates */}
<div className={styles.cardHeader}><span>Dates importantes</span></div>
<div className={styles.row} style={{ marginBottom: 6, flexWrap: 'wrap' }}>
<input lang="fr-FR" type="date" className={styles.input} value={idate} onChange={(e) => setIDate(e.target.value)}/>
<input className={styles.input} placeholder="Label" value={ilabel} onChange={(e) => setILabel(e.target.value)}/>
<button className={styles.btn} onClick={addImportantDate} disabled={!idate}>Ajouter</button>
</div>
<div className={styles.listScroll} style={{ maxHeight: '25vh' }}>
{(friend?.importantDates || []).map((d, idx) => (
<div key={idx} className={styles.switchRow}>
<div style={{ minWidth: 120, fontWeight: 600 }}>{d?.date || ''}</div>
<div style={{ flex: 1 }}>{d?.label || ''}</div>
<button className={styles.btnSecondary} onClick={() => removeImportantDate(idx)}>Supprimer</button>
</div>
))}
{(friend?.importantDates || []).length === 0 && <div style={{ padding: 8, color: '#666' }}>Aucune date.</div>}
</div>
{/* Gifts */}
<div className={styles.cardHeader}><span>Cadeaux offerts</span></div>
<div className={styles.row} style={{ marginBottom: 6, flexWrap: 'wrap' }}>
<input lang="fr-FR" type="date" className={styles.input} value={g.date} onChange={(e) => setG({ ...g, date: e.target.value })}/>
<input className={styles.input} placeholder="Occasion" value={g.occasion} onChange={(e) => setG({ ...g, occasion: e.target.value })}/>
<input className={styles.input} placeholder="Description" value={g.description} onChange={(e) => setG({ ...g, description: e.target.value })} style={{ minWidth: 240 }}/>
<input className={styles.input} placeholder="Image URL" value={g.image} onChange={(e) => setG({ ...g, image: e.target.value })}/>
<button className={styles.btn} onClick={addGift} disabled={!g.date && !g.description}>Ajouter</button>
</div>
<div className={styles.listScroll} style={{ maxHeight: '25vh' }}>
{(friend?.gifts || []).map((x, idx) => (
<div key={idx} className={styles.switchRow}>
<div style={{ minWidth: 110, fontWeight: 600 }}>{x?.date || ''}</div>
<div style={{ flex: 1 }}>{x?.occasion ? `${x.occasion} — ` : ''}{x?.description || ''}</div>
{x?.image ? <a href={x.image} target="_blank" rel="noreferrer" className={styles.itemMeta}>image</a> : null}
<button className={styles.btnSecondary} onClick={() => removeGift(idx)}>Supprimer</button>
</div>
))}
{(friend?.gifts || []).length === 0 && <div style={{ padding: 8, color: '#666' }}>Aucun cadeau.</div>}
</div>
{/* Postcards */}
<div className={styles.cardHeader}><span>Cartes postales</span></div>
<div className={styles.row} style={{ marginBottom: 6, flexWrap: 'wrap' }}>
<input lang="fr-FR" type="date" className={styles.input} value={pc.date} onChange={(e) => setPC({ ...pc, date: e.target.value })}/>
<input className={styles.input} placeholder="Lieu" value={pc.location} onChange={(e) => setPC({ ...pc, location: e.target.value })}/>
<input className={styles.input} placeholder="Description" value={pc.description} onChange={(e) => setPC({ ...pc, description: e.target.value })} style={{ minWidth: 240 }}/>
<input className={styles.input} placeholder="Image URL" value={pc.image} onChange={(e) => setPC({ ...pc, image: e.target.value })}/>
<button className={styles.btn} onClick={addPostcard}>Ajouter</button>
</div>
<div className={styles.listScroll} style={{ maxHeight: '25vh' }}>
{(friend?.postcards || []).map((x, idx) => (
<div key={idx} className={styles.switchRow}>
<div style={{ minWidth: 110, fontWeight: 600 }}>{x?.date || ''}</div>
<div style={{ flex: 1 }}>{x?.location ? `${x.location} — ` : ''}{x?.description || ''}</div>
{x?.image ? <a href={x.image} target="_blank" rel="noreferrer" className={styles.itemMeta}>image</a> : null}
<button className={styles.btnSecondary} onClick={() => removePostcard(idx)}>Supprimer</button>
</div>
))}
{(friend?.postcards || []).length === 0 && <div style={{ padding: 8, color: '#666' }}>Aucune carte.</div>}
</div>
</div>
{/* Events */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<span>Events</span>
</div>
{/* Add Event */}
<div className={styles.row} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
<input
lang="fr-FR"
type="date"
className={styles.input}
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<input
className={styles.input}
placeholder="Location (optional)"
value={location}
onChange={(e) => setLocation(e.target.value)}
style={{ minWidth: 160 }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<input
className={styles.input}
placeholder="Title (required)"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<textarea
className={styles.textarea}
placeholder="Notes (required)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.row} style={{ marginBottom: 12 }}>
<button className={styles.btn} onClick={submitEvent} disabled={!title.trim() || !notes.trim()}>
Add Event
</button>
</div>
{/* Timeline */}
<div className={styles.timeline}>
{eventsDesc.map((e) => (
<div key={e.id + e.date} className={styles.timelineEvent}>
<div className={styles.timelineDate}>
{isoDate(e.date)}
</div>
<div
style={{ fontWeight: 700, cursor: 'pointer' }}
onClick={() => (window.location.href = `/dunbar/event/${eventSlug(e)}`)}
title="Open event details"
>
{e.title || '(untitled)'}
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')}
</div>
{e.location ? (
<div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</div>
) : null}
{/* Tag chips */}
{(() => {
const tags = extractTags(e.notes || '');
return tags.length ? (
<div className={styles.tagRow}>
{tags.slice(0, 10).map((t) => (
<span key={t} className={styles.tagChip}>#{t}</span>
))}
</div>
) : null;
})()}
</div>
))}
{eventsDesc.length === 0 && (
<div style={{ color: '#666' }}>No events yet add your first memory above.</div>
)}
</div>
</div>
</div>
</div>
);
}
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 {
degreeMap,
edgesFromFriends,
drawLabel,
clamp,
} from '@/lib/dunbar';
import { extractLocations } from '@/lib/dunbar-nlp';
// We only rely on d3-force for physics. Zoom/pan/drag implemented manually to avoid extra deps.
export default function NetworkTab({ friends, toggleRel, openFriendDetail }) {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const simRef = useRef(null);
const rafRef = useRef(0);
// Canvas sizing
const [size, setSize] = useState({ w: 800, h: 500 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
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();
}, []);
// Transform for pan/zoom
const [transform, setTransform] = useState({ k: 1, x: 0, y: 0 });
const worldFromScreen = (sx, sy) => ({
x: (sx - transform.x) / transform.k,
y: (sy - transform.y) / transform.k,
});
// Location filter (offline gazetteer)
const [locFilter, setLocFilter] = useState('');
// Selection + UI state
const [selectedId, setSelectedId] = useState(null);
const [focusNeighbors, setFocusNeighbors] = useState(false);
// labelDensity: 'none' | 'focus' | 'all'
const [labelDensity, setLabelDensity] = useState('focus');
const [searchText, setSearchText] = useState('');
const searchInputRef = useRef(null);
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);
m.set(f.id, Array.from(new Set(locs)));
}
return m;
}, [friends]);
const locationOptions = useMemo(() => {
const counts = new Map();
for (const [, locs] of locByFriend.entries()) {
for (const name of locs) counts.set(name, (counts.get(name) || 0) + 1);
}
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([name]) => name);
}, [locByFriend]);
const filteredFriends = useMemo(() => {
if (!locFilter) return friends;
return friends.filter((f) => (locByFriend.get(f.id) || []).includes(locFilter));
}, [friends, locFilter, locByFriend]);
// Search (by name) results
const searchResults = useMemo(() => {
const q = (searchText || '').toLowerCase().trim();
if (!q) return [];
return friends
.filter((f) => (f.name || '').toLowerCase().includes(q))
.slice(0, 10);
}, [friends, searchText]);
// Neighbor set for selection
const neighborSet = useMemo(() => {
if (!selectedId) return new Set();
const set = new Set();
for (const [a, b] of edgesFromFriends(filteredFriends)) {
if (String(a) === String(selectedId)) set.add(String(b));
if (String(b) === String(selectedId)) set.add(String(a));
}
return set;
}, [filteredFriends, selectedId]);
// Graph data derived from friends
const nodes = useMemo(() => filteredFriends.map(f => ({
id: String(f.id),
name: f.name,
})), [filteredFriends]);
const degreeM = useMemo(() => degreeMap(filteredFriends), [filteredFriends]);
const links = useMemo(
() => edgesFromFriends(filteredFriends).map(([a,b]) => ({ source: String(a), target: String(b) })),
[filteredFriends]
);
// Node state (positions) persisted across renders
const nodeStateRef = useRef(new Map()); // id -> {x,y,vx,vy}
const getNodeState = (id) => {
let s = nodeStateRef.current.get(id);
if (!s) {
// seed around center
s = {
x: (Math.random() - 0.5) * 200,
y: (Math.random() - 0.5) * 200,
vx: 0, vy: 0,
};
nodeStateRef.current.set(id, s);
}
return s;
};
// Simulation setup/refresh
const physicsOn = true;
useEffect(() => {
let stopped = false;
let sim;
async function setup() {
const d3 = await import('d3-force');
const d3force = d3; // module namespace
// Build d3 nodes referencing our state map
const d3Nodes = nodes.map(n => {
const s = getNodeState(n.id);
return { id: n.id, x: s.x, y: s.y, vx: s.vx, vy: s.vy };
});
const d3Links = links.map(l => ({ source: l.source, target: l.target }));
sim = d3force.forceSimulation(d3Nodes)
.force('charge', d3force.forceManyBody().strength(-80))
.force('link', d3force.forceLink(d3Links).id(d => d.id).distance(80).strength(0.2))
.force('center', d3force.forceCenter(0, 0))
.force('collide', d3force.forceCollide(18));
sim.alpha(0.8).alphaTarget(0.03).restart();
sim.on('tick', () => {
if (stopped) return;
// Persist positions back to our state map
for (const n of d3Nodes) {
const s = getNodeState(n.id);
s.x = n.x;
s.y = n.y;
s.vx = n.vx || 0;
s.vy = n.vy || 0;
}
requestDraw();
});
simRef.current = sim;
}
setup();
return () => {
stopped = true;
if (sim) sim.stop();
simRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, links]); // rebuild when graph updates
// Drawing
const requestDraw = () => {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
draw();
});
};
const nodeRadius = (id) => {
const deg = degreeM.get(id) || 0;
return clamp(6 + deg * 0.8, 6, 18);
// color by degree buckets as specified
};
const nodeColor = (id) => {
const deg = degreeM.get(id) || 0;
if (deg >= 10) return '#2c5530'; // dark green
if (deg >= 5) return '#5a9960'; // medium green
if (deg >= 1) return '#a0c0a0'; // light green
return '#c0c0c0'; // gray for 0
};
const draw = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const { w, h } = size;
// DPR scaling
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.save();
ctx.scale(dpr, dpr);
// clear
ctx.clearRect(0, 0, w, h);
// apply transform (pan/zoom)
ctx.translate(transform.x, transform.y);
ctx.scale(transform.k, transform.k);
// Draw links
for (const l of links) {
const a = getNodeState(String(l.source));
const b = getNodeState(String(l.target));
if (!a || !b) continue;
const isFocus =
selectedId &&
(String(l.source) === String(selectedId) || String(l.target) === String(selectedId));
ctx.beginPath();
ctx.lineWidth = (isFocus ? 2 : 1) / transform.k;
ctx.strokeStyle = isFocus
? '#5a9960'
: focusNeighbors && selectedId
? 'rgba(224,224,224,0.35)'
: '#e0e0e0';
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
// Draw nodes
for (const n of nodes) {
const s = getNodeState(n.id);
const r = nodeRadius(n.id);
const isSel = selectedId && String(n.id) === String(selectedId);
const isNbr = selectedId && neighborSet.has(String(n.id));
ctx.beginPath();
if (isSel) {
ctx.fillStyle = '#2c5530';
} else if (isNbr) {
ctx.fillStyle = '#5a9960';
} else {
ctx.fillStyle =
focusNeighbors && selectedId ? 'rgba(160,192,160,0.4)' : nodeColor(n.id);
}
ctx.arc(s.x, s.y, r, 0, Math.PI * 2);
ctx.fill();
}
// Labels: based on density
for (const n of nodes) {
const showLabel =
labelDensity === 'all' ||
(labelDensity === 'focus' &&
(String(n.id) === String(selectedId) || neighborSet.has(String(n.id))));
if (!showLabel) continue;
const s = getNodeState(n.id);
const r = nodeRadius(n.id);
const fontPx = 12 / transform.k;
const yOff = (r + 6) / transform.k; // a bit above the node
drawLabel(ctx, n.name, s.x, s.y - yOff, '#333', fontPx);
}
ctx.restore();
};
useEffect(() => { requestDraw(); }, [size, transform, nodes, links, degreeM]); // redraw on deps
// Interaction
const stateRef = useRef({
pendingDragId: null, // node id awaiting threshold before dragging
draggingNodeId: null, // active drag
downAt: { x: 0, y: 0 }, // screen coords where mouse down occurred
dragOffset: { x: 0, y: 0 },
panning: false,
panStart: { x: 0, y: 0 },
transformStart: { x: 0, y: 0 },
linkDraftFrom: null, // id when in edit mode and dragging a link
lastMouse: { x: 0, y: 0 },
});
const [editMode, setEditMode] = useState(false);
const pickNodeAt = (sx, sy) => {
const { x, y } = worldFromScreen(sx, sy);
// simple hit test with expanded radius
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
const s = getNodeState(n.id);
const r = nodeRadius(n.id) + 4;
const dx = x - s.x;
const dy = y - s.y;
if (dx * dx + dy * dy <= r * r) return n.id;
}
return null;
};
const onMouseDown = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const id = pickNodeAt(sx, sy);
stateRef.current.lastMouse = { x: sx, y: sy };
if (id) {
if (editMode) {
// start link draft
stateRef.current.linkDraftFrom = id;
} else {
// prepare to drag node (threshold)
stateRef.current.pendingDragId = id;
stateRef.current.downAt = { x: sx, y: sy };
const { x, y } = worldFromScreen(sx, sy);
const s = getNodeState(id);
stateRef.current.dragOffset = { x: s.x - x, y: s.y - y };
// Nudge simulation once dragging starts
}
} else {
// start panning
stateRef.current.panning = true;
stateRef.current.panStart = { x: sx, y: sy };
stateRef.current.transformStart = { x: transform.x, y: transform.y };
}
};
const onMouseMove = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
stateRef.current.lastMouse = { x: sx, y: sy };
// activate dragging if threshold exceeded
if (stateRef.current.pendingDragId && !stateRef.current.draggingNodeId) {
const dx = sx - stateRef.current.downAt.x;
const dy = sy - stateRef.current.downAt.y;
if (dx * dx + dy * dy > 16) {
stateRef.current.draggingNodeId = stateRef.current.pendingDragId;
stateRef.current.pendingDragId = null;
if (simRef.current) simRef.current.alphaTarget(0.1).restart();
}
}
if (stateRef.current.draggingNodeId) {
const id = stateRef.current.draggingNodeId;
const { x, y } = worldFromScreen(sx, sy);
const s = getNodeState(id);
s.x = x + stateRef.current.dragOffset.x;
s.y = y + stateRef.current.dragOffset.y;
// reflect back to simulation node if exists
if (simRef.current) {
const d3node = simRef.current.nodes().find(n => n.id === id);
if (d3node) {
d3node.fx = s.x;
d3node.fy = s.y;
}
}
requestDraw();
return;
}
if (stateRef.current.panning) {
const dx = sx - stateRef.current.panStart.x;
const dy = sy - stateRef.current.panStart.y;
setTransform(t => ({ ...t, x: stateRef.current.transformStart.x + dx, y: stateRef.current.transformStart.y + dy }));
return;
}
// if link draft, just redraw (we draw draft in overlay)
if (stateRef.current.linkDraftFrom) {
requestDraw();
}
};
const onMouseUp = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const overId = pickNodeAt(sx, sy);
if (stateRef.current.draggingNodeId) {
const id = stateRef.current.draggingNodeId;
stateRef.current.draggingNodeId = null;
stateRef.current.pendingDragId = null;
// release fixed position so sim can settle (unless physics off)
if (simRef.current) {
const d3node = simRef.current.nodes().find(n => n.id === id);
if (d3node) {
d3node.fx = null;
d3node.fy = null;
}
}
} else if (stateRef.current.panning) {
stateRef.current.panning = false;
} else if (stateRef.current.linkDraftFrom) {
const from = stateRef.current.linkDraftFrom;
stateRef.current.linkDraftFrom = null;
if (overId && overId !== from) {
// Toggle bidirectional link
toggleRel?.(from, overId);
}
} else if (!editMode && overId) {
// Click node: select and center; optionally open detail on double-click later
setSelectedId(overId);
centerOnNode(overId);
// keep existing behavior: open friend detail
openFriendDetail?.(overId);
}
requestDraw();
};
const zoomAt = (factor, sx, sy) => {
const rect = canvasRef.current?.getBoundingClientRect();
const cx = rect ? rect.width / 2 : 0;
const cy = rect ? rect.height / 2 : 0;
const px = sx ?? cx;
const py = sy ?? cy;
setTransform((t) => {
const newK = clamp(t.k * factor, 0.2, 4);
const wx0 = (px - t.x) / t.k;
const wy0 = (py - t.y) / t.k;
const x = px - wx0 * newK;
const y = py - wy0 * newK;
return { k: newK, x, y };
});
};
const panBy = (dx, dy) => {
setTransform(t => ({ ...t, x: t.x + dx, y: t.y + dy }));
};
const onWheel = (e) => {
e.preventDefault();
const rect = canvasRef.current.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const factor = Math.exp(-e.deltaY * 0.0015);
zoomAt(factor, sx, sy);
};
// Overlay draw (draft link)
useEffect(() => {
// draw overlay line for link draft
if (!stateRef.current.linkDraftFrom) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const { w, h } = size;
const dpr = window.devicePixelRatio || 1;
ctx.save();
ctx.scale(dpr, dpr);
// simply call main draw then overlay the draft line
draw();
const fromId = stateRef.current.linkDraftFrom;
const s = getNodeState(fromId);
const { x: sx, y: sy } = stateRef.current.lastMouse;
const p = worldFromScreen(sx, sy);
ctx.translate(transform.x, transform.y);
ctx.scale(transform.k, transform.k);
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(p.x, p.y);
ctx.strokeStyle = '#5a9960';
ctx.lineWidth = 2 / transform.k;
ctx.setLineDash([6 / transform.k, 4 / transform.k]);
ctx.stroke();
ctx.restore();
});
// Keyboard navigation scoped to container
const onKeyDown = (e) => {
const PAN = 40;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
e.preventDefault();
searchInputRef.current?.focus();
return;
}
if (e.key.toLowerCase() === 'f') {
// center on selection
if (selectedId) centerOnNode(selectedId);
return;
}
if (e.key === 'Escape') {
setSelectedId(null);
return;
}
if (e.key === 'ArrowLeft') setTransform(t => ({ ...t, x: t.x + PAN }));
else if (e.key === 'ArrowRight') setTransform(t => ({ ...t, x: t.x - PAN }));
else if (e.key === 'ArrowUp') setTransform(t => ({ ...t, y: t.y + PAN }));
else if (e.key === 'ArrowDown') setTransform(t => ({ ...t, y: t.y - PAN }));
else if (e.key === '+' || e.key === '=') zoomAt(1.2);
else if (e.key === '-' || e.key === '_') zoomAt(1 / 1.2);
else if (e.key === '0') setTransform({ k: 1, x: 0, y: 0 });
else if (e.key.toLowerCase() === 'e') setEditMode(v => !v);
};
// Focus container on mount so arrows/+/− work immediately
useEffect(() => {
containerRef.current?.focus();
}, []);
// Center viewport on a node
const centerOnNode = (id) => {
const s = getNodeState(String(id));
if (!s) return;
const rect = containerRef.current?.getBoundingClientRect();
const w = rect ? rect.width : size.w;
const h = rect ? rect.height : size.h;
// Target center in screen coords
const cx = w / 2;
const cy = h / 2;
setTransform((t) => {
const x = cx - s.x * t.k;
const y = cy - s.y * t.k;
return { ...t, x, y };
});
requestDraw();
};
return (
<div>
<div className={styles.graphToolbar}>
<button
className={styles.btnSecondary}
onClick={() => setEditMode(v => !v)}
title="Toggle edit mode to create/remove connections"
>
Mode: {editMode ? 'Edit' : 'View'}
</button>
<select
className={styles.select}
value={locFilter}
onChange={(e) => setLocFilter(e.target.value)}
title="Filter by location (offline gazetteer)"
>
<option value="">All locations</option>
{locationOptions.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
<button
className={styles.btnSecondary}
onClick={() => selectedId && centerOnNode(selectedId)}
disabled={!selectedId}
title="Center on selection"
>
Center
</button>
<button
className={styles.btnSecondary}
onClick={() => setFocusNeighbors(v => !v)}
title="Focus selected + neighbors"
>
Focus: {focusNeighbors ? 'ON' : 'OFF'}
</button>
<select
className={styles.select}
value={labelDensity}
onChange={(e) => setLabelDensity(e.target.value)}
title="Label density"
>
<option value="none">Labels: None</option>
<option value="focus">Labels: Focus</option>
<option value="all">Labels: All</option>
</select>
<span className={styles.badge}>{nodes.length} nodes · {links.length} edges</span>
</div>
{editMode && (
<div className={styles.banner}>Edit mode: Drag from one node to another to toggle a connection.</div>
)}
<div className={styles.twoCol} style={{ gap: 12 }}>
{/* Sidebar */}
<div className={styles.card}>
<div className={styles.cardHeader}><span>Find</span></div>
<div className={styles.row} style={{ marginBottom: 8 }}>
<input
ref={searchInputRef}
className={styles.input}
placeholder="Search by name (Ctrl/Cmd+F)"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.listScroll} style={{ maxHeight: '30vh' }}>
{searchResults.map((f) => (
<div
key={f.id}
className={styles.listItem}
onClick={() => {
setSelectedId(String(f.id));
centerOnNode(String(f.id));
}}
style={
String(selectedId) === String(f.id) ? { background: '#f5fbf7' } : undefined
}
>
<div className={styles.itemTitle}>{f.name}</div>
</div>
))}
{searchText && searchResults.length === 0 && (
<div style={{ padding: 8, color: '#666' }}>No matches.</div>
)}
</div>
{selectedId ? (
<div style={{ marginTop: 8 }}>
<div className={styles.cardHeader}><span>Selection</span></div>
<div className={styles.row} style={{ flexWrap: 'wrap' }}>
<button
className={styles.btnSecondary}
onClick={() => centerOnNode(selectedId)}
title="Center on selection"
>
Center
</button>
<button
className={styles.btnSecondary}
onClick={() => setSelectedId(null)}
title="Clear selection"
>
Clear
</button>
<button
className={styles.btnSecondary}
onClick={() => openFriendDetail?.(selectedId)}
title="Open friend detail"
>
Open
</button>
</div>
<div className={styles.itemMeta} style={{ marginTop: 6 }}>
Neighbors: {neighborSet.size}
</div>
</div>
) : null}
</div>
{/* Canvas */}
<div
ref={containerRef}
className={styles.canvasWrap}
tabIndex={0}
role="application"
aria-label="Network graph"
onKeyDown={onKeyDown}
style={{ outline: 'none' }}
>
<canvas
ref={canvasRef}
width={size.w}
height={size.h}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onWheel={onWheel}
/>
<div className={styles.floatingControls}>
<button className={styles.ctrlBtn} onClick={() => panBy(-40, 0)} aria-label="Pan left"></button>
<button className={styles.ctrlBtn} onClick={() => panBy(0, -40)} aria-label="Pan up"></button>
<button className={styles.ctrlBtn} onClick={() => panBy(40, 0)} aria-label="Pan right"></button>
<button className={styles.ctrlBtn} onClick={() => zoomAt(1 / 1.2)} aria-label="Zoom out"></button>
<button className={styles.ctrlBtn} onClick={() => setTransform({ k: 1, x: 0, y: 0 })} aria-label="Reset"></button>
<button className={styles.ctrlBtn} onClick={() => zoomAt(1.2)} aria-label="Zoom in"></button>
<button className={`${styles.ctrlBtn} ${styles.ctrlWide}`} onClick={() => setEditMode(v => !v)} aria-label="Toggle edit">
{editMode ? 'Edit:ON' : 'Edit:OFF'}
</button>
</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>
);
}
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { normalizeImportedPayload, isoDate } from '@/lib/dunbar';
import { computeUpcomingAnniversaries } from '@/lib/dunbar';
const LS_KEY = 'dunbar-state-v1';
// Helpers
const uuid = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `id_${Date.now()}_${Math.random().toString(36).slice(2)}`);
const nowISO = () => new Date().toISOString();
function serializeState(state) {
const friends = state.friends.map((f) => ({
...f,
relationships: Array.from(f.relationships || []),
}));
return JSON.stringify({ friends, selectedFriendId: state.selectedFriendId });
}
function deserializeState(json) {
try {
const data = JSON.parse(json);
if (!data || !Array.isArray(data.friends)) return null;
const friends = data.friends.map((f) => ({
...f,
relationships: new Set(f.relationships || []),
events: Array.isArray(f.events) ? f.events : [],
}));
return { friends, selectedFriendId: data.selectedFriendId || null };
} catch {
return null;
}
}
function computeLastInteraction(friend) {
if (!friend.events || friend.events.length === 0) return null;
const max = friend.events.reduce((acc, e) => {
const t = new Date(e.date).getTime();
return Math.max(acc, isNaN(t) ? 0 : t);
}, 0);
return max ? new Date(max).toISOString() : null;
}
const initialState = {
friends: [],
selectedFriendId: null,
selectedEventId: null,
};
function reducer(state, action) {
switch (action.type) {
case 'LOAD': {
return action.payload || state;
}
case 'ADD_FRIEND': {
const name = String(action.payload?.name || '').trim();
if (!name) return state;
const newFriend = {
id: uuid(),
name,
birthday: null,
notes: '',
// rich profile fields
likes: [],
dislikes: [],
foodLikes: '',
foodDislikes: '',
wifiPassword: '',
carModel: '',
workplace: '',
schedule: '',
futureIdeas: [],
quotes: [],
projects: [],
importantDates: [], // [{ date: 'YYYY-MM-DD', label: string }]
gifts: [], // [{ date, occasion, description, image }]
postcards: [], // [{ date, location, description, image }]
relationships: new Set(),
events: [],
lastInteraction: null,
};
return { ...state, friends: [...state.friends, newFriend] };
}
case 'REMOVE_FRIEND': {
const id = action.payload?.id;
if (!id) return state;
const friends = state.friends
.filter((f) => f.id !== id)
.map((f) => {
if (f.relationships?.has(id)) {
const rel = new Set(f.relationships);
rel.delete(id);
return { ...f, relationships: rel };
}
return f;
});
return {
...state,
friends,
selectedFriendId: state.selectedFriendId === id ? null : state.selectedFriendId,
};
}
case 'RENAME_FRIEND': {
const { id, name } = action.payload || {};
if (!id) return state;
const n = String(name || '').trim();
if (!n) return state;
const friends = state.friends.map((f) => (f.id === id ? { ...f, name: n } : f));
return { ...state, friends };
}
case 'SET_BIRTHDAY': {
const { id, birthday } = action.payload || {};
if (!id) return state;
const b = birthday ? String(birthday).slice(0, 10) : null;
const friends = state.friends.map((f) => (f.id === id ? { ...f, birthday: b } : f));
return { ...state, friends };
}
case 'SET_FRIEND_NOTES': {
const { id, notes } = action.payload || {};
if (!id) return state;
const friends = state.friends.map((f) => (f.id === id ? { ...f, notes: String(notes || '') } : f));
return { ...state, friends };
}
case 'SELECT_FRIEND': {
const id = action.payload?.id ?? null;
return { ...state, selectedFriendId: id };
}
case 'SELECT_EVENT': {
const id = action.payload?.id ?? null;
return { ...state, selectedEventId: id };
}
case 'TOGGLE_REL': {
const a = action.payload?.aId;
const b = action.payload?.bId;
if (!a || !b || a === b) return state;
const friends = state.friends.map((f) => {
if (f.id === a) {
const rel = new Set(f.relationships || []);
rel.has(b) ? rel.delete(b) : rel.add(b);
return { ...f, relationships: rel };
}
if (f.id === b) {
const rel = new Set(f.relationships || []);
rel.has(a) ? rel.delete(a) : rel.add(a);
return { ...f, relationships: rel };
}
return f;
});
return { ...state, friends };
}
case 'ADD_EVENT': {
const { date, title, notes, participants = [], location } = action.payload || {};
// Normalize to YYYY-MM-DD (Paris local semantics handled at render/grouping time)
const dateStr = typeof date === 'string' ? date.slice(0, 10) : isoDate(date);
if (!dateStr || !String(title || '').trim() || !String(notes || '').trim() || !participants.length) return state;
const eventId = uuid();
// Create one logical event id applied to each participant for deduplication across views
const friends = state.friends.map((f) => {
if (participants.includes(f.id)) {
const ev = {
id: eventId,
date: dateStr,
title: String(title),
notes: String(notes),
location: location ? String(location) : undefined,
participants: [...participants],
};
const events = Array.isArray(f.events) ? [...f.events, ev] : [ev];
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
return f;
});
return { ...state, friends, selectedEventId: eventId };
}
case 'UPDATE_EVENT': {
const { id, patch } = action.payload || {};
if (!id || !patch) return state;
// Find a canonical copy of the event to merge with
let canonical = null;
for (const f of state.friends) {
const found = (f.events || []).find((e) => e.id === id);
if (found) {
canonical = found;
break;
}
}
if (!canonical) return state;
const nextParticipants = Array.isArray(patch.participants)
? [...patch.participants]
: [...(canonical.participants || [])];
// Normalized updated event object
const updated = {
...canonical,
...patch,
participants: nextParticipants,
};
const participantSet = new Set(nextParticipants);
const friends = state.friends.map((f) => {
const hasBefore = (f.events || []).some((e) => e.id === id);
const shouldHave = participantSet.has(f.id);
// Remove if no longer participant
if (hasBefore && !shouldHave) {
const events = (f.events || []).filter((e) => e.id !== id);
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Add if newly participant
if (!hasBefore && shouldHave) {
const events = Array.isArray(f.events) ? [...f.events, updated] : [updated];
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Update if present and still participant
if (hasBefore && shouldHave) {
const events = (f.events || []).map((e) => (e.id === id ? updated : e));
const lastInteraction = computeLastInteraction({ ...f, events });
return { ...f, events, lastInteraction };
}
// Neither before nor after → unchanged
return f;
});
return { ...state, friends };
}
case 'UPDATE_FRIEND': {
const { id, patch } = action.payload || {};
if (!id || !patch) return state;
const friends = state.friends.map((f) => (f.id === id ? { ...f, ...patch } : f));
return { ...state, friends };
}
case 'RESET': {
return { ...initialState };
}
default:
return state;
}
}
/**
* Dunbar data store with localStorage persistence and derived helpers.
*/
export function useDunbarStore() {
const didInitRef = useRef(false);
const [state, dispatch] = useReducer(reducer, initialState);
// Load persisted state (client-only)
useEffect(() => {
if (didInitRef.current) return;
didInitRef.current = true;
if (typeof window === 'undefined') return;
const raw = window.localStorage.getItem(LS_KEY);
const loaded = raw ? deserializeState(raw) : null;
if (loaded) {
// Patch lastInteraction on load
const patched = {
...loaded,
friends: loaded.friends.map((f) => ({
...f,
lastInteraction: computeLastInteraction(f),
})),
};
dispatch({ type: 'LOAD', payload: patched });
}
}, []);
// Auto-save to localStorage on change
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const serialized = serializeState(state);
window.localStorage.setItem(LS_KEY, serialized);
} catch {
// ignore
}
}, [state]);
// Actions
const addFriend = useCallback((name) => dispatch({ type: 'ADD_FRIEND', payload: { name } }), []);
const removeFriend = useCallback((id) => dispatch({ type: 'REMOVE_FRIEND', payload: { id } }), []);
const selectFriend = useCallback((id) => dispatch({ type: 'SELECT_FRIEND', payload: { id } }), []);
const toggleRelationship = useCallback((aId, bId) => dispatch({ type: 'TOGGLE_REL', payload: { aId, bId } }), []);
const addEvent = useCallback((payload) => dispatch({ type: 'ADD_EVENT', payload }), []);
const selectEvent = useCallback((id) => dispatch({ type: 'SELECT_EVENT', payload: { id } }), []);
const updateEvent = useCallback((id, patch) => dispatch({ type: 'UPDATE_EVENT', payload: { id, patch } }), []);
const resetData = useCallback(() => {
if (typeof window !== 'undefined') {
const ok = window.confirm('This will clear all Dunbar data. Continue?');
if (!ok) return;
window.localStorage.removeItem(LS_KEY);
}
dispatch({ type: 'RESET' });
}, []);
const renameFriend = useCallback((id, name) => {
dispatch({ type: 'RENAME_FRIEND', payload: { id, name } });
}, []);
const setBirthday = useCallback((id, birthday) => {
dispatch({ type: 'SET_BIRTHDAY', payload: { id, birthday } });
}, []);
const setFriendNotes = useCallback((id, notes) => {
dispatch({ type: 'SET_FRIEND_NOTES', payload: { id, notes } });
}, []);
const loadFromPayload = useCallback((payload) => {
try {
const normalized = normalizeImportedPayload(payload);
const patched = {
...normalized,
friends: normalized.friends.map((f) => ({
...f,
lastInteraction: computeLastInteraction(f),
})),
};
dispatch({ type: 'LOAD', payload: patched });
} catch (e) {
// eslint-disable-next-line no-console
console.error('Dunbar import failed', e);
}
}, []);
// Derived helpers
const friendMap = useMemo(() => {
const m = new Map();
for (const f of state.friends) m.set(f.id, f);
return m;
}, [state.friends]);
const getFriendById = useCallback((id) => friendMap.get(id) || null, [friendMap]);
const getConnectionCount = useCallback(
(id) => {
const f = friendMap.get(id);
return f?.relationships ? f.relationships.size : 0;
},
[friendMap]
);
const getEventCount = useCallback(
(id) => {
const f = friendMap.get(id);
return Array.isArray(f?.events) ? f.events.length : 0;
},
[friendMap]
);
// Stats
const stats = useMemo(() => {
const friends = state.friends;
let edges = 0;
let totalEvents = 0;
const now = Date.now();
const ninety = 90 * 24 * 60 * 60 * 1000;
let activeFriends = 0;
for (const f of friends) {
edges += (f.relationships?.size || 0);
const evCount = Array.isArray(f.events) ? f.events.length : 0;
totalEvents += evCount;
const hasRecent = (f.events || []).some((e) => now - new Date(e.date).getTime() <= ninety);
if (hasRecent) activeFriends += 1;
}
const uniqueConnections = Math.floor(edges / 2);
const avgEvents = friends.length ? +(totalEvents / friends.length).toFixed(2) : 0;
return {
connections: uniqueConnections,
activeFriends,
totalEvents,
avgEventsPerFriend: avgEvents,
};
}, [state.friends]);
// Orbits (close = 5+ in 90d OR 10+ in 365d OR 20+ total)
const orbitBuckets = useMemo(() => {
const now = Date.now();
const ninety = 90 * 24 * 60 * 60 * 1000;
const year = 365 * 24 * 60 * 60 * 1000;
const metrics = state.friends.map((f) => {
const evs = Array.isArray(f.events) ? f.events : [];
let c90 = 0;
let c365 = 0;
for (const e of evs) {
const t = new Date(e.date).getTime();
if (!isNaN(t)) {
const dt = now - t;
if (dt <= ninety) c90 += 1;
if (dt <= year) c365 += 1;
}
}
const total = evs.length;
return { id: f.id, c90, c365, total };
});
const inner = [];
const middle = [];
const outer = [];
for (const m of metrics) {
const isInner = m.c90 >= 5 || m.c365 >= 10 || m.total >= 20;
if (isInner) inner.push(m.id);
else if (m.c90 >= 2) middle.push(m.id);
else outer.push(m.id);
}
return { inner, middle, outer };
}, [state.friends]);
// Event index by id across all friends (for timeline dedup)
const eventIndex = useMemo(() => {
const idx = new Map();
for (const f of state.friends) {
for (const e of f.events || []) {
if (!idx.has(e.id)) {
idx.set(e.id, { ...e, participants: new Set(e.participants || []) });
} else {
const curr = idx.get(e.id);
for (const pid of e.participants || []) curr.participants.add(pid);
}
}
}
// Normalize participants back to array
const merged = Array.from(idx.values()).map((e) => ({ ...e, participants: Array.from(e.participants) }));
return merged;
}, [state.friends]);
// Anniversaries and reminders (next 21 days)
const anniversaries = useMemo(() => {
return computeUpcomingAnniversaries(state.friends, 21);
}, [state.friends]);
const updateFriend = useCallback((id, patch) => {
dispatch({ type: 'UPDATE_FRIEND', payload: { id, patch } });
}, []);
return {
state,
friends: state.friends,
selectedFriendId: state.selectedFriendId,
selectedEventId: state.selectedEventId,
actions: {
addFriend,
removeFriend,
renameFriend,
setBirthday,
setFriendNotes,
updateFriend,
selectFriend,
toggleRelationship,
addEvent,
selectEvent,
updateEvent,
resetData,
loadFromPayload,
},
helpers: {
getFriendById,
getConnectionCount,
getEventCount,
},
derived: {
stats,
orbitBuckets,
eventIndex,
anniversaries,
},
};
}
...@@ -4,14 +4,30 @@ import styles from "./layout.module.css"; ...@@ -4,14 +4,30 @@ import styles from "./layout.module.css";
import utilStyles from "../styles/utils.module.css"; import utilStyles from "../styles/utils.module.css";
import Link from "next/link"; import Link from "next/link";
import Router from 'next/router' import Router from 'next/router'
import { useRouter } from 'next/router'
const name = "PLN"; const name = "PLN";
export const siteTitle = "PLN's Works"; 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 twitterHandle = "@PaulLouisNech";
export const description = "PLN's Selected Works"; export const description = "PLN's Selected Works";
export default function Layout({ children, home }) { 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 ( return (
<div className={styles.container}> <div className={styles.container}>
<Head> <Head>
...@@ -78,7 +94,7 @@ export default function Layout({ children, home }) { ...@@ -78,7 +94,7 @@ export default function Layout({ children, home }) {
</div> </div>
)} )}
<footer> <footer>
PLN 2024 | PLN 2025 |
<a <a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app" href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank" target="_blank"
...@@ -86,6 +102,20 @@ export default function Layout({ children, home }) { ...@@ -86,6 +102,20 @@ export default function Layout({ children, home }) {
> >
</a> </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> </footer>
</div> </div>
); );
......
.container { .container {
max-width: 42rem; max-width: 113rem;
padding: 0 1rem; padding: 0 1rem;
margin: 3rem auto 6rem; margin: 3rem auto 6rem;
} }
......
// Demo dataset generator for Dunbar — 50 French profiles with clusters, relationships, events, birthdays, and rich profile fields.
// Usage:
// import { generateDemoPayload } from '@/lib/dunbar-demo';
// const payload = generateDemoPayload(50);
// actions.loadFromPayload(payload);
function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function sample(arr) {
return arr[randInt(0, arr.length - 1)];
}
function pickN(arr, n) {
const a = [...arr];
const out = [];
n = Math.min(n, a.length);
for (let i = 0; i < n; i++) {
const idx = randInt(0, a.length - 1);
out.push(a[idx]);
a.splice(idx, 1);
}
return out;
}
function uid(prefix = 'id') {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
function toYMD(d) {
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function daysAgo(n) {
const d = new Date();
d.setUTCDate(d.getUTCDate() - n);
// snap to UTC midnight
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
// Name pools — French + diverse origins
const FIRST_NAMES = [
'Chloé','Lucas','Inès','Mehdi','Aya','Léa','Adam','Camille','Noah','Fatou','Hugo','Nina','Yanis','Aïcha',
'Théo','Manon','Rayan','Jiwoo','Lina','Sofiane','Océane','Moussa','Clara','Youssef','Tao','Sacha','Zoé',
'Naïm','Imane','Omar','Yara','Rachid','Samira','Lucie','Alexandre','Selma','Pierre','Karim','Leïla','Oce',
'Val','Raph','Kyu','Igor','Sonia','Oceane','Mathis','Maëlys','Nora','Mina','Maya'
];
const LAST_NAMES = [
'Dupont','Martin','Bernard','Durand','Moreau','Lefebvre','Fournier','Mercier','Faure','Andre',
'Benali','El Mansouri','Traoré','Diop','Nguyen','Zhang','Haddad','Khan','Rossi','Gonzalez',
'Peigné','Mahé Millet','Porte','Lefèvre','Petit','Renaud','Barbier','Lemaire','Noël','Boucher'
];
// Parisian clichés and tags
const PLACES = [
'Canal Saint-Martin','Buttes-Chaumont','Montmartre','Bourse de Commerce','Belleville','Le Marais','Station F',
'Parc Monceau','Jardin du Luxembourg','Bercy','Pont des Arts','Bastille','Aligre','Saint-Germain','Parc de la Villette'
];
const ACTIVITIES = [
'apéro','expo','vernissage','vélo','café','pique-nique','concert','ciné','footing','meetup','brunch','fromages','boulot'
];
const TAGS = ['#apero','#expo','#vernissage','#velo','#run','#cafe','#picnic','#concert','#cine','#meetup','#famille','#boulot','#amis','#dodo','#art'];
const QUOTES = [
'Toujours partant·e pour un café !','Jamais sans mon vélo.','Le fromage, c’est la vie.','On se fait un apéro ?',
'Team Boulot-Boulot-Boulot.','Paris sous la pluie, c’est mieux.','Vélib’ et liberté.','Vivement le week-end !'
];
const FOOD_LIKES = ['fromage','pain','viennoiseries','tapas','sushi','ramen','couscous','tajine','falafel','galettes','crêpes','pizza','pasta'];
const FOOD_DISLIKES = ['choux de Bruxelles','réglisse','coriandre','anchois','abats'];
const CAR_MODELS = ['Twingo','Clio','208','Model 3','Zoé','C3','Yaris','Micra','206','Golf','Corsa'];
const WORKPLACES = ['Station F','Quartier Latour-Maubourg','La Défense','Bercy','Montparnasse','Opéra','Bastille','République','Nation'];
function makeNamePool(count) {
const names = new Set();
while (names.size < count) {
names.add(`${sample(FIRST_NAMES)} ${sample(LAST_NAMES)}`);
}
return Array.from(names);
}
function generateClusters(count, minSize = 8, maxSize = 14) {
const clusters = [];
let remaining = count;
while (remaining > 0) {
const size = Math.min(remaining, randInt(minSize, maxSize));
clusters.push(size);
remaining -= size;
}
return clusters;
}
function connectGraph(friendIds, clusters) {
// Build intra/inter cluster connections (undirected)
const edges = new Set();
let index = 0;
const clusterSlices = clusters.map(size => {
const slice = friendIds.slice(index, index + size);
index += size;
return slice;
});
// intra cluster
for (const slice of clusterSlices) {
const p = 0.4 + Math.random() * 0.2; // 0.4..0.6 dense
for (let i = 0; i < slice.length; i++) {
for (let j = i + 1; j < slice.length; j++) {
if (Math.random() < p) {
const a = slice[i], b = slice[j];
const key = a < b ? `${a}::${b}` : `${b}::${a}`;
edges.add(key);
}
}
}
}
// inter cluster sparse bridges
for (let c = 0; c < clusterSlices.length - 1; c++) {
const a = sample(clusterSlices[c]);
const b = sample(clusterSlices[c + 1]);
const key = a < b ? `${a}::${b}` : `${b}::${a}`;
edges.add(key);
}
return Array.from(edges).map(k => k.split('::'));
}
function generateEvents(friendIds, friendById, edges) {
// Create shared event ids; distribute across last ~365 days
const eventsByFriend = new Map(friendIds.map(id => [id, []]));
const E = randInt(friendIds.length * 1.2, friendIds.length * 2.2);
for (let i = 0; i < E; i++) {
const date = toYMD(daysAgo(randInt(0, 360)));
const place = sample(PLACES);
const act = sample(ACTIVITIES);
const note = `${act} ${place} ${sample(TAGS)} ${sample(TAGS)}`;
// choose 1–4 participants: bias to connected pairs
let participants = [];
if (Math.random() < 0.6 && edges.length > 0) {
const [a, b] = sample(edges);
const group = [a, b];
if (Math.random() < 0.5) group.push(sample(friendIds));
if (Math.random() < 0.3) group.push(sample(friendIds));
participants = Array.from(new Set(group));
} else {
participants = pickN(friendIds, randInt(1, 4));
}
const id = uid('ev');
const ev = {
id,
date,
notes: note,
location: place,
participants: participants,
};
for (const pid of participants) {
eventsByFriend.get(pid).push(ev);
}
}
// Sort newest first for lastInteraction logic ease
for (const id of friendIds) {
eventsByFriend.get(id).sort((a, b) => (a.date < b.date ? 1 : -1));
}
return eventsByFriend;
}
export function generateDemoPayload(count = 50) {
const names = makeNamePool(count);
const friends = names.map(n => ({ id: uid('f'), name: n }));
const friendIds = friends.map(f => f.id);
// Split into 3–5 clusters
const clusters = generateClusters(count, 8, 14);
const edges = connectGraph(friendIds, clusters);
// Build friend map shell with rich fields
const friendById = new Map();
for (const f of friends) {
friendById.set(f.id, {
id: f.id,
name: f.name,
birthday: toYMD(daysAgo(randInt(7000, 20000))), // 19–55 years old approx
notes: `Ami·e rencontré·e à ${sample(PLACES)}. ${sample(TAGS)} ${sample(TAGS)}`,
likes: `Aime ${sample(FOOD_LIKES)} et ${sample(ACTIVITIES)}`,
dislikes: `N'aime pas ${sample(FOOD_DISLIKES)}`,
foodLikes: sample(FOOD_LIKES),
foodDislikes: sample(FOOD_DISLIKES),
wifiPassword: Math.random().toString(36).slice(2, 10),
carModel: sample(CAR_MODELS),
workplace: sample(WORKPLACES),
schedule: ['9h-17h','8h-16h','10h-18h','horaires flex'].slice(randInt(0,3)),
futureIdeas: `Aller au ${sample(PLACES)}; ${sample(ACTIVITIES)}; ${sample(ACTIVITIES)}.`,
quotes: sample(QUOTES),
importantDates: [],
gifts: [],
postcards: [],
relationships: new Set(),
events: [],
lastInteraction: null,
});
}
// Relationships
for (const [a, b] of edges) {
friendById.get(a).relationships.add(b);
friendById.get(b).relationships.add(a);
}
// Events
const eventsByFriend = generateEvents(friendIds, friendById, edges);
for (const id of friendIds) {
const list = eventsByFriend.get(id) || [];
friendById.get(id).events = list;
friendById.get(id).lastInteraction = list.length ? list[0].date : null;
}
// Sprinkle important dates / gifts / postcards
for (const id of friendIds) {
const f = friendById.get(id);
// 0–3 dates
const k = randInt(0, 3);
for (let i = 0; i < k; i++) {
f.importantDates.push({
date: toYMD(daysAgo(randInt(0, 365))),
label: sample(['Concert','Déménagement','Nouvel emploi','Voyage','Soirée mémorable']),
});
}
// 0–2 gifts
const gk = randInt(0, 2);
for (let i = 0; i < gk; i++) {
f.gifts.push({
date: toYMD(daysAgo(randInt(0, 365))),
occasion: sample(['Anniversaire','Noël','Remerciement','Fête']),
description: sample(['Livre','Bouteille de vin','Plante verte','Boîte de chocolats','Écharpe']),
image: '',
});
}
// 0–2 postcards
const pk = randInt(0, 2);
for (let i = 0; i < pk; i++) {
f.postcards.push({
date: toYMD(daysAgo(randInt(0, 365))),
location: sample(['Bretagne','Marseille','Lyon','Biarritz','Annecy','Lisbonne','Rome']),
description: sample(['Belle météo','Vieux port','Plage','Musées','Randonnée']),
image: '',
});
}
}
// Convert to export payload shape (relationships: arrays)
const outFriends = friendIds.map(id => {
const f = friendById.get(id);
return {
id: f.id,
name: f.name,
birthday: f.birthday,
notes: f.notes,
likes: f.likes,
dislikes: f.dislikes,
foodLikes: f.foodLikes,
foodDislikes: f.foodDislikes,
wifiPassword: f.wifiPassword,
carModel: f.carModel,
workplace: f.workplace,
schedule: f.schedule,
futureIdeas: f.futureIdeas,
quotes: f.quotes,
importantDates: f.importantDates,
gifts: f.gifts,
postcards: f.postcards,
relationships: Array.from(f.relationships),
events: f.events.map(e => ({
id: e.id,
date: e.date,
notes: e.notes,
location: e.location,
participants: e.participants,
})),
};
});
return {
schema: 'dunbar-v1',
version: '1.0.0',
savedAt: new Date().toISOString(),
selectedFriendId: null,
friends: outFriends,
};
}
/**
* Dunbar NLP utilities (client-only, local-first).
* Zero network calls. Pure functions usable in browser.
*
* Exports:
* - stripDiacritics, normalizeText
* - detectLang
* - tokenize, ngrams
* - removeStopwords
* - computeTfIdf, topKeywordsForDocs
* - extractLocations
* - extractTopics (beta, with graceful fallback)
*
* Notes:
* - We reuse stopword lists from the 'stopword' package (already in deps).
* - We avoid heavy stemming here; Minisearch in dunbar-search.js already does FR stemming for search.
*/
import { fr as FR_LIST, en as EN_LIST } from 'stopword';
// ---------- Normalization ----------
/** Remove diacritics but preserve base letters (é → e). */
export function stripDiacritics(s = '') {
try {
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
} catch {
// Fallback for environments missing normalize()
return s;
}
}
/**
* Normalize text for analysis:
* - lowercases
* - optionally strip diacritics
* - collapses whitespace
* - preserves hashtags when requested
*/
export function normalizeText(text = '', { removeDiacritics = true, preserveHashtags = true } = {}) {
let t = String(text || '').toLowerCase();
if (removeDiacritics) t = stripDiacritics(t);
if (!preserveHashtags) t = t.replace(/#/g, ' ');
// collapse whitespace
t = t.replace(/\s+/g, ' ').trim();
return t;
}
// ---------- Language detection (simple heuristic) ----------
const FR_STOP_SET = new Set(FR_LIST || []);
const EN_STOP_SET = new Set(EN_LIST || []);
/**
* Very light lang detection: compare stopword hits FR vs EN.
* Returns 'fr' | 'en' | null (if undecided).
*/
export function detectLang(text = '') {
const norm = normalizeText(text, { removeDiacritics: true, preserveHashtags: false });
const tokens = (norm.match(/[\p{L}\p{N}]+/gu) || []).filter(Boolean);
let frScore = 0;
let enScore = 0;
for (const t of tokens) {
if (FR_STOP_SET.has(t)) frScore++;
if (EN_STOP_SET.has(t)) enScore++;
}
if (frScore === 0 && enScore === 0) return null;
if (frScore > enScore) return 'fr';
if (enScore > frScore) return 'en';
return null;
}
// ---------- Tokenization, stopwords, n-grams ----------
/**
* Tokenize text:
* - keeps hashtags (#word) if keepHashtags=true
* - returns lowercase tokens
*/
export function tokenize(text = '', { keepHashtags = true, removeDiacritics = true } = {}) {
const hashtags = keepHashtags ? (text.match(/#[\p{L}\p{N}_-]+/gu) || []).map((t) => t.toLowerCase()) : [];
const norm = normalizeText(text.replace(/#/g, ' '), { removeDiacritics, preserveHashtags: false });
const words = (norm.match(/[\p{L}\p{N}][\p{L}\p{N}'’_-]*/gu) || []).map((w) => w.toLowerCase());
// dedupe while preserving order for hashtags
const seen = new Set();
const out = [];
for (const h of hashtags) {
if (!seen.has(h)) {
seen.add(h);
out.push(h);
}
}
for (const w of words) {
if (!seen.has(w)) {
seen.add(w);
out.push(w);
}
}
return out;
}
/** Generate n-grams (array of strings joined by space) from a token array. */
export function ngrams(tokens = [], n = 2) {
const res = [];
for (let i = 0; i <= tokens.length - n; i++) {
res.push(tokens.slice(i, i + n).join(' '));
}
return res;
}
/** Remove stopwords per language (keeps hashtags always). */
export function removeStopwords(tokens = [], lang = null) {
if (!lang) return tokens;
const set = lang === 'fr' ? FR_STOP_SET : lang === 'en' ? EN_STOP_SET : null;
if (!set) return tokens;
return tokens.filter((t) => t.startsWith('#') || !set.has(t));
}
// ---------- TF-IDF + keywords ----------
/**
* Build per-document TF-IDF vectors.
* docs: [{ id, text }]
* returns:
* {
* termsByDoc: Map(id -> Map(term -> tfidf)),
* df: Map(term -> docFreq),
* idf: Map(term -> idf),
* tokensByDoc: Map(id -> tokens),
* }
*/
export function computeTfIdf(
docs = [],
{ lang = null, includeNGrams = false, nGramSizes = [2], maxVocab = 5000 } = {}
) {
const tokensByDoc = new Map();
const termFreqByDoc = new Map();
const df = new Map();
// 1) tokenize + local term frequencies
for (const d of docs) {
const toks = removeStopwords(tokenize(d.text || '', { keepHashtags: true, removeDiacritics: true }), lang);
const withN = [toks];
if (includeNGrams) {
for (const n of nGramSizes) withN.push(ngrams(toks, n));
}
const all = withN.flat();
tokensByDoc.set(d.id, all);
const tf = new Map();
for (const t of all) {
tf.set(t, (tf.get(t) || 0) + 1);
}
termFreqByDoc.set(d.id, tf);
// update document frequency
for (const term of new Set(all)) {
df.set(term, (df.get(term) || 0) + 1);
}
}
// 2) vocabulary capping (optional)
if (maxVocab && df.size > maxVocab) {
// keep most frequent terms across docs
const topTerms = Array.from(df.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, maxVocab)
.map(([t]) => t);
const keep = new Set(topTerms);
for (const [docId, tf] of termFreqByDoc.entries()) {
for (const term of Array.from(tf.keys())) {
if (!keep.has(term)) tf.delete(term);
}
}
for (const term of Array.from(df.keys())) {
if (!keep.has(term)) df.delete(term);
}
}
// 3) compute IDF
const N = docs.length || 1;
const idf = new Map();
for (const [term, dfi] of df.entries()) {
const val = Math.log((N + 1) / (dfi + 1)) + 1; // smooth IDF
idf.set(term, val);
}
// 4) compute TF-IDF per doc (normalize with augmented TF)
const termsByDoc = new Map();
for (const [docId, tf] of termFreqByDoc.entries()) {
let tfMax = 1;
for (const v of tf.values()) tfMax = Math.max(tfMax, v);
const vec = new Map();
for (const [term, freq] of tf.entries()) {
const tfw = 0.5 + (0.5 * freq) / tfMax;
const score = tfw * (idf.get(term) || 0);
vec.set(term, score);
}
termsByDoc.set(docId, vec);
}
return { termsByDoc, df, idf, tokensByDoc };
}
/**
* Get top keywords per doc.
* returns array: [{ id, keywords: Array<[term, score]> }]
*/
export function topKeywordsForDocs(docs = [], { lang = null, topK = 8, includeNGrams = false } = {}) {
const { termsByDoc } = computeTfIdf(docs, { lang, includeNGrams });
const res = [];
for (const d of docs) {
const vec = termsByDoc.get(d.id) || new Map();
const sorted = Array.from(vec.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, topK);
res.push({ id: d.id, keywords: sorted });
}
return res;
}
// ---------- Locations (offline gazetteer) ----------
const MINI_GAZETTEER = [
// Countries (FR/EN names)
{ type: 'country', name: 'france', aliases: ['république française'], iso2: 'FR' },
{ type: 'country', name: 'germany', aliases: ['deutschland', 'allemagne'], iso2: 'DE' },
{ type: 'country', name: 'spain', aliases: ['españa', 'espagne'], iso2: 'ES' },
{ type: 'country', name: 'united states', aliases: ['usa', 'us', 'etats-unis', 'états-unis', 'u.s.'], iso2: 'US' },
{ type: 'country', name: 'united kingdom', aliases: ['uk', 'u.k.', 'royaume-uni', 'britain'], iso2: 'GB' },
{ type: 'country', name: 'italy', aliases: ['italia', 'italie'], iso2: 'IT' },
{ type: 'country', name: 'belgium', aliases: ['belgique'], iso2: 'BE' },
{ type: 'country', name: 'switzerland', aliases: ['schweiz', 'suisse', 'svizzera'], iso2: 'CH' },
// Cities (sample, extend as needed)
{ type: 'city', name: 'paris', country: 'FR', aliases: [] },
{ type: 'city', name: 'lyon', country: 'FR', aliases: [] },
{ type: 'city', name: 'marseille', country: 'FR', aliases: [] },
{ type: 'city', name: 'berlin', country: 'DE', aliases: [] },
{ type: 'city', name: 'barcelona', country: 'ES', aliases: ['barcelone'] },
{ type: 'city', name: 'london', country: 'GB', aliases: ['londres'] },
{ type: 'city', name: 'geneva', country: 'CH', aliases: ['genève', 'geneve'] },
{ type: 'city', name: 'brussels', country: 'BE', aliases: ['bruxelles'] },
];
/** Escape regex special chars */
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Extract location mentions using a small offline gazetteer.
* Returns unique matches: [{ type, name, country?, iso2? }]
*/
export function extractLocations(text = '', { gazetteer = MINI_GAZETTEER } = {}) {
const normText = ' ' + normalizeText(text, { removeDiacritics: true, preserveHashtags: true }) + ' ';
const found = [];
const seen = new Set();
for (const entry of gazetteer) {
const names = [entry.name, ...(entry.aliases || [])]
.map((n) => stripDiacritics(n.toLowerCase().trim()))
.filter(Boolean);
for (const n of names) {
// match on word boundaries in normalized text
const pattern = new RegExp(`(^|\\s)${escapeRegExp(n)}(\\s|[.,;:!?])`, 'i');
if (pattern.test(normText)) {
const key = `${entry.type}:${entry.name}:${entry.country || entry.iso2 || ''}`;
if (!seen.has(key)) {
seen.add(key);
found.push({
type: entry.type,
name: entry.name,
country: entry.country,
iso2: entry.iso2,
});
}
break; // avoid duplicate alias hits
}
}
}
return found;
}
// ---------- Topics (beta) ----------
/**
* Try to extract topics with a dynamic import of 'lda' if available.
* If not, fall back to a simple co-occurrence-based grouping derived from TF-IDF.
*
* docs: [{ id, text }]
* returns: Array<{ terms: Array<[term, score]>, documents: Array<id> }>
*/
export async function extractTopics(
docs = [],
{ topics = 5, termsPerTopic = 6, lang = null } = {}
) {
// Attempt dynamic LDA if user installs a tiny LDA package like 'lda'
try {
const mod = await import('lda'); // will throw if not installed
const lda = mod.default || mod;
// 'lda' expects an array of documents (strings). Signature: lda(docs, numberOfTopics, termsPerTopic, alpha?, eta?, random?)
const topicSets = lda(
docs.map((d) => String(d.text || '')),
topics,
termsPerTopic
);
// topicSets: Array of Array<{ term, probability } | [term, prob] >
return topicSets.map((topic) => {
const terms = topic.map((t) => {
if (Array.isArray(t)) return [t[0], t[1]];
if (t && typeof t === 'object') return [t.term, t.probability ?? t.prob];
return [String(t), 0];
});
return { terms, documents: [] };
});
} catch {
// Fallback: build pseudo-topics from TF-IDF heads
return fallbackTopics(docs, { topics, termsPerTopic, lang });
}
}
function fallbackTopics(docs, { topics = 5, termsPerTopic = 6, lang = null } = {}) {
const { termsByDoc } = computeTfIdf(docs, { lang, includeNGrams: false });
// Global scores
const global = new Map();
for (const [, vec] of termsByDoc.entries()) {
for (const [term, score] of vec.entries()) {
global.set(term, (global.get(term) || 0) + score);
}
}
const topTerms = Array.from(global.entries()).sort((a, b) => b[1] - a[1]).slice(0, topics);
const topicsOut = [];
for (const [headTerm, headScore] of topTerms) {
// collect co-occurring terms from docs that contain the head
const co = new Map();
const docIds = new Set();
for (const [docId, vec] of termsByDoc.entries()) {
if (vec.has(headTerm)) {
docIds.add(docId);
for (const [term, s] of vec.entries()) {
if (term === headTerm) continue;
co.set(term, (co.get(term) || 0) + s);
}
}
}
const topCo = Array.from(co.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, Math.max(0, termsPerTopic - 1));
topicsOut.push({
terms: [[headTerm, headScore], ...topCo],
documents: Array.from(docIds),
});
}
return topicsOut;
}
export default {
stripDiacritics,
normalizeText,
detectLang,
tokenize,
ngrams,
removeStopwords,
computeTfIdf,
topKeywordsForDocs,
extractLocations,
extractTopics,
};
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);
}
// Dunbar shared utilities — keep UI components lean and consistent.
// Import via: import { ... } from '@/lib/dunbar';
// Locale & time zone defaults (French, Paris)
export const LOCALE = 'fr-FR';
export const TIMEZONE = 'Europe/Paris';
// ------------------------------
// Internal helpers for timezone-safe local dates
// ------------------------------
function partsInTZ(dateLike, timeZone = TIMEZONE) {
const d = dateLike instanceof Date ? dateLike : new Date(dateLike);
if (isNaN(d.getTime())) return null;
const fmt = new Intl.DateTimeFormat(LOCALE, {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// fr-FR gives dd/mm/yyyy — use formatToParts to recompose safely
const parts = fmt.formatToParts(d);
const map = Object.fromEntries(parts.map(p => [p.type, p.value]));
// Ensure 2-digit month/day
const yyyy = map.year;
const mm = map.month;
const dd = map.day;
return { yyyy, mm, dd };
}
function ymdInTZ(dateLike, timeZone = TIMEZONE) {
const p = partsInTZ(dateLike, timeZone);
if (!p) return '';
return `${p.yyyy}-${p.mm}-${p.dd}`;
}
// ------------------------------
// Dates & text formatting
// ------------------------------
export function isoDate(dateLike, timeZone = TIMEZONE) {
return ymdInTZ(dateLike, timeZone);
}
export function fullDateLabel(dateLike, locale = LOCALE, timeZone = TIMEZONE) {
try {
const d = dateLike instanceof Date ? dateLike : new Date(dateLike);
if (isNaN(d.getTime())) return '';
return d.toLocaleDateString(locale, {
timeZone,
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
} catch {
return '';
}
}
export function firstWords(text, n = 3) {
if (!text) return '';
const words = String(text).trim().split(/\s+/);
return words.slice(0, n).join(' ');
}
// Extract hashtags (lowercased, without #)
export function extractTags(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);
}
// Slugify helper for URLs
export function slugify(text = '') {
const s = String(text || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return s || 'x';
}
// Event slug helper: stable and mostly human — title slug + short id suffix
export function eventSlug(evOrTitle, idMaybe) {
if (typeof evOrTitle === 'object' && evOrTitle) {
const title = evOrTitle.title || '';
const id = evOrTitle.id || '';
return `${slugify(title)}-${String(id).slice(-6)}`;
}
const title = String(evOrTitle || '');
const id = String(idMaybe || '');
return `${slugify(title)}-${id.slice(-6)}`;
}
// Friend slug helper: name slug + short id suffix
export function friendSlug(friendOrName, idMaybe) {
if (typeof friendOrName === 'object' && friendOrName) {
const name = friendOrName.name || '';
const id = friendOrName.id || '';
return `${slugify(name)}-${String(id).slice(-6)}`;
}
const name = String(friendOrName || '');
const id = String(idMaybe || '');
return `${slugify(name)}-${id.slice(-6)}`;
}
// Quick-date helpers (ISO YYYY-MM-DD) — Paris local calendar
export function todayISO() {
return isoDate(new Date(), TIMEZONE);
}
export function yesterdayISO() {
const d = new Date();
d.setDate(d.getDate() - 1);
return isoDate(d, TIMEZONE);
}
export function weekAgoISO() {
const d = new Date();
d.setDate(d.getDate() - 7);
return isoDate(d, TIMEZONE);
}
export function startOfMonthISO() {
const d = new Date();
d.setDate(1);
return isoDate(d, TIMEZONE);
}
// ------------------------------
// Events helpers
// ------------------------------
// Sort newest first by date
export function sortEventsDesc(events) {
return [...(events || [])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
// Group events (array) by local Paris day (YYYY-MM-DD). Returns [{ dateKey, label, items }]
export function groupEventsByDay(events, locale = LOCALE, timeZone = TIMEZONE) {
const groups = new Map();
for (const e of events || []) {
const key = isoDate(e.date, timeZone);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
const out = [];
for (const [dateKey, items] of groups.entries()) {
out.push({
dateKey,
label: fullDateLabel(dateKey, locale, timeZone),
items: sortEventsDesc(items),
});
}
// Sort groups by day (newest first)
out.sort((a, b) => new Date(b.dateKey).getTime() - new Date(a.dateKey).getTime());
return out;
}
// ------------------------------
// Orbits helpers (positions & colors)
// ------------------------------
export function distributeOnCircle(ids = [], radius = 100, cx = 0, cy = 0, startAngle = 0) {
const n = ids.length || 1;
const step = (2 * Math.PI) / n;
const pos = new Map();
ids.forEach((id, i) => {
const angle = startAngle + i * step;
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
pos.set(id, { x, y, angle });
});
return pos;
}
// Activity color coding based on last-90-days interaction counts
export function colorByActivity(count90) {
if (count90 >= 5) return '#2c5530'; // dark green
if (count90 >= 2) return '#5a9960'; // medium green
return '#a0c0a0'; // light green
}
// ------------------------------
// Network helpers
// ------------------------------
export function degreeMap(friends = []) {
const m = new Map();
for (const f of friends) {
m.set(f.id, (f.relationships && f.relationships.size) || 0);
}
return m;
}
export function edgesFromFriends(friends = []) {
// Build unique undirected edges [a,b] with a < b to avoid duplicates
const seen = new Set();
const edges = [];
for (const f of friends) {
for (const to of f.relationships || []) {
const a = String(f.id);
const b = String(to);
if (a === b) continue;
const key = a < b ? `${a}::${b}` : `${b}::${a}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push([a, b]);
}
}
return edges;
}
// ------------------------------
// Math & drawing helpers
// ------------------------------
export const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
export const lerp = (a, b, t) => a + (b - a) * t;
// Canvas label drawing with white stroke for contrast
export function drawLabel(ctx, text, x, y, color = '#333', fontPx = 12) {
ctx.save();
ctx.font = `${fontPx}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif`;
ctx.lineWidth = Math.max(2, fontPx / 4);
ctx.strokeStyle = '#fff';
ctx.strokeText(text, x, y);
ctx.fillStyle = color;
ctx.fillText(text, x, y);
ctx.restore();
}
// ------------------------------
// Versioned import/export helpers
// ------------------------------
export const DATA_VERSION = '1.0.0';
export const DATA_SCHEMA = 'dunbar-v1';
// Prepare JSON-safe snapshot (relationships as arrays)
export function makeExportPayload(state) {
const friends = (state.friends || []).map(f => ({
id: f.id,
name: f.name,
birthday: f.birthday || null,
notes: f.notes || '',
// rich profile
likes: Array.isArray(f.likes) ? f.likes : (f.likes ? String(f.likes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
dislikes: Array.isArray(f.dislikes) ? f.dislikes : (f.dislikes ? String(f.dislikes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
foodLikes: f.foodLikes || '',
foodDislikes: f.foodDislikes || '',
wifiPassword: f.wifiPassword || '',
carModel: f.carModel || '',
workplace: f.workplace || '',
schedule: f.schedule || '',
futureIdeas: Array.isArray(f.futureIdeas) ? f.futureIdeas : (f.futureIdeas ? String(f.futureIdeas).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
quotes: Array.isArray(f.quotes) ? f.quotes : (f.quotes ? String(f.quotes).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
projects: Array.isArray(f.projects) ? f.projects : (f.projects ? String(f.projects).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
importantDates: Array.isArray(f.importantDates) ? f.importantDates.map(x => ({
date: x?.date || null,
label: x?.label || '',
})) : [],
gifts: Array.isArray(f.gifts) ? f.gifts.map(x => ({
date: x?.date || null,
occasion: x?.occasion || '',
description: x?.description || '',
image: x?.image || '',
})) : [],
postcards: Array.isArray(f.postcards) ? f.postcards.map(x => ({
date: x?.date || null,
location: x?.location || '',
description: x?.description || '',
image: x?.image || '',
})) : [],
relationships: Array.from(f.relationships || []),
events: Array.isArray(f.events) ? f.events.map(ev => ({
id: ev.id,
date: ev.date,
title: ev.title || '',
notes: ev.notes,
location: ev.location,
participants: Array.isArray(ev.participants) ? [...ev.participants] : [],
})) : [],
}));
return {
schema: DATA_SCHEMA,
version: DATA_VERSION,
savedAt: new Date().toISOString(),
selectedFriendId: state.selectedFriendId || null,
friends,
};
}
export function normalizeImportedPayload(payload) {
if (!payload) throw new Error('Empty payload');
// Accept same schema or a minimal legacy shape { friends, selectedFriendId }
const friendsRaw = Array.isArray(payload.friends) ? payload.friends : [];
const friends = friendsRaw.map(f => ({
id: f.id,
name: f.name || '',
birthday: f.birthday || null,
notes: f.notes || '',
// rich profile (defaults)
likes: Array.isArray(f.likes) ? f.likes : (f.likes ? String(f.likes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
dislikes: Array.isArray(f.dislikes) ? f.dislikes : (f.dislikes ? String(f.dislikes).split(/[,\\n]/).map(s => s.trim()).filter(Boolean) : []),
foodLikes: f.foodLikes || '',
foodDislikes: f.foodDislikes || '',
wifiPassword: f.wifiPassword || '',
carModel: f.carModel || '',
workplace: f.workplace || '',
schedule: f.schedule || '',
futureIdeas: Array.isArray(f.futureIdeas) ? f.futureIdeas : (f.futureIdeas ? String(f.futureIdeas).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
quotes: Array.isArray(f.quotes) ? f.quotes : (f.quotes ? String(f.quotes).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
projects: Array.isArray(f.projects) ? f.projects : (f.projects ? String(f.projects).split(/[,\n]/).map(s => s.trim()).filter(Boolean) : []),
importantDates: Array.isArray(f.importantDates) ? f.importantDates.map(x => ({
date: x?.date || null,
label: x?.label || '',
})) : [],
gifts: Array.isArray(f.gifts) ? f.gifts.map(x => ({
date: x?.date || null,
occasion: x?.occasion || '',
description: x?.description || '',
image: x?.image || '',
})) : [],
postcards: Array.isArray(f.postcards) ? f.postcards.map(x => ({
date: x?.date || null,
location: x?.location || '',
description: x?.description || '',
image: x?.image || '',
})) : [],
relationships: new Set(Array.isArray(f.relationships) ? f.relationships : []),
events: Array.isArray(f.events) ? f.events.map(ev => ({
id: ev.id,
date: ev.date,
title: ev.title || '',
notes: ev.notes || '',
location: ev.location,
participants: Array.isArray(ev.participants) ? ev.participants : [],
})) : [],
lastInteraction: null, // computed by store
}));
return {
friends,
selectedFriendId: payload.selectedFriendId || null,
};
}
// ------------------------------
// Anniversaries helpers (birthday / half-birthday / 6m-12m since first/last events)
// ------------------------------
function parseYMD(ymd) {
if (!ymd) return null;
// Interpret as UTC midnight to avoid TZ drift across locales
const [y, m, d] = String(ymd).split('-').map(v => parseInt(v, 10));
if (!y || !m || !d) return null;
return new Date(Date.UTC(y, (m - 1), d));
}
function toYMD(date) {
if (!date || isNaN(date.getTime())) return '';
// Keep using local Paris label elsewhere; for storage we use YYYY-MM-DD UTC
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonthsUTC(date, n) {
const d = new Date(date.getTime());
const y = d.getUTCFullYear();
const m = d.getUTCMonth();
const day = d.getUTCDate();
// Move to first of target month then clamp day
const target = new Date(Date.UTC(y, m + n, 1));
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
target.setUTCDate(Math.min(day, lastDay));
return target;
}
function daysDiffUTC(a, b) {
const MS = 24 * 60 * 60 * 1000;
const at = Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), a.getUTCDate());
const bt = Date.UTC(b.getUTCFullYear(), b.getUTCMonth(), b.getUTCDate());
return Math.round((bt - at) / MS);
}
function nextMultipleMonthAnniv(baseYMD, stepMonths, today = new Date()) {
const base = parseYMD(baseYMD);
if (!base) return null;
let candidate = new Date(base.getTime());
// Increase by step until >= today
while (candidate < today) {
candidate = addMonthsUTC(candidate, stepMonths);
}
return candidate;
}
function nextBirthday(birthdayYMD, today = new Date()) {
const b = parseYMD(birthdayYMD);
if (!b) return null;
const year = today.getUTCFullYear();
let next = new Date(Date.UTC(year, b.getUTCMonth(), b.getUTCDate()));
if (next < today) {
next = new Date(Date.UTC(year + 1, b.getUTCMonth(), b.getUTCDate()));
}
return next;
}
function nextHalfBirthday(birthdayYMD, today = new Date()) {
const b = parseYMD(birthdayYMD);
if (!b) return null;
const nextB = nextBirthday(birthdayYMD, today);
const half = addMonthsUTC(nextB, -6); // the previous half-birthday relative to next birthday
// Ensure we pick the next upcoming within the next cycle
if (half >= today) return half;
return addMonthsUTC(half, 12);
}
/**
* Compute upcoming anniversaries in the next `windowDays` days.
* Returns sorted array of:
* { date: 'YYYY-MM-DD', friendId, friendName, kind: 'birthday'|'half-birthday'|'first-6m'|'first-12m'|'last-6m'|'last-12m', label }
*/
export function computeUpcomingAnniversaries(friends = [], windowDays = 21) {
const today = new Date();
const items = [];
for (const f of friends || []) {
// birthday + half-birthday
if (f.birthday) {
const nb = nextBirthday(f.birthday, today);
const nh = nextHalfBirthday(f.birthday, today);
if (nb) {
const d = daysDiffUTC(today, nb);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(nb),
friendId: f.id,
friendName: f.name,
kind: 'birthday',
label: `Anniversaire de ${f.name}`,
});
}
}
if (nh) {
const d = daysDiffUTC(today, nh);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(nh),
friendId: f.id,
friendName: f.name,
kind: 'half-birthday',
label: `Demi‑anniversaire de ${f.name}`,
});
}
}
}
// First & last event anchors
if (Array.isArray(f.events) && f.events.length) {
const evs = (f.events || [])
.map(e => ({ ...e, _t: parseYMD(e.date) }))
.filter(e => !!e._t)
.sort((a, b) => a._t - b._t);
const first = evs.length ? evs[0]._t : null;
const last = evs.length ? evs[evs.length - 1]._t : null;
const firstEv = evs.length ? evs[0] : null;
const lastEv = evs.length ? evs[evs.length - 1] : null;
if (first) {
const n6 = nextMultipleMonthAnniv(toYMD(first), 6, today);
const n12 = nextMultipleMonthAnniv(toYMD(first), 12, today);
if (n6) {
const d = daysDiffUTC(today, n6);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(n6),
friendId: f.id,
friendName: f.name,
kind: 'first-6m',
label: `6 mois depuis le 1er événement avec ${f.name}`,
anchorTitle: firstEv ? firstWords(firstEv.notes || '', 5) : '',
anchorTags: firstEv ? extractTags(firstEv.notes || '') : [],
});
}
}
if (n12) {
const d = daysDiffUTC(today, n12);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(n12),
friendId: f.id,
friendName: f.name,
kind: 'first-12m',
label: `1 an depuis le 1er événement avec ${f.name}`,
anchorTitle: firstEv ? firstWords(firstEv.notes || '', 5) : '',
anchorTags: firstEv ? extractTags(firstEv.notes || '') : [],
});
}
}
}
if (last) {
const n6 = nextMultipleMonthAnniv(toYMD(last), 6, today);
const n12 = nextMultipleMonthAnniv(toYMD(last), 12, today);
if (n6) {
const d = daysDiffUTC(today, n6);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(n6),
friendId: f.id,
friendName: f.name,
kind: 'last-6m',
label: `6 mois depuis le dernier événement avec ${f.name}`,
anchorTitle: lastEv ? firstWords(lastEv.notes || '', 5) : '',
anchorTags: lastEv ? extractTags(lastEv.notes || '') : [],
});
}
}
if (n12) {
const d = daysDiffUTC(today, n12);
if (d >= 0 && d <= windowDays) {
items.push({
date: toYMD(n12),
friendId: f.id,
friendName: f.name,
kind: 'last-12m',
label: `1 an depuis le dernier événement avec ${f.name}`,
anchorTitle: lastEv ? firstWords(lastEv.notes || '', 5) : '',
anchorTags: lastEv ? extractTags(lastEv.notes || '') : [],
});
}
}
}
}
}
items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
return items;
}
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -5,7 +5,12 @@ ...@@ -5,7 +5,12 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "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": { "engines": {
"node": ">=18.17.0" "node": ">=18.17.0"
...@@ -14,10 +19,13 @@ ...@@ -14,10 +19,13 @@
"@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/aspect-ratio": "^0.4.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"d3-force": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29", "hydra-synth": "^1.3.29",
"marked": "^15.0.11", "marked": "^15.0.11",
"minisearch": "^7.1.2",
"next": "^15.3.0", "next": "^15.3.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.2.0", "react": "^18.2.0",
...@@ -30,10 +38,14 @@ ...@@ -30,10 +38,14 @@
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.0", "remark": "^14.0.0",
"remark-html": "^15.0.0", "remark-html": "^15.0.0",
"snowball-stemmers": "^0.6.0",
"stopword": "^3.1.5",
"swiper": "^11.2.6" "swiper": "^11.2.6"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.61", "@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 }) { ...@@ -67,10 +67,10 @@ export default function ParVagues({ lives }) {
const backgroundRef = useRef(null); const backgroundRef = useRef(null);
const audioRef = 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 => { const futureEvents = lives.filter(live => {
return new Date(live.date) > new Date(); return new Date(live.date) > new Date();
}); }).sort((a, b) => new Date(a.date) - new Date(b.date));
// Define section content // Define section content
const sections = { const sections = {
...@@ -119,6 +119,20 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12) ...@@ -119,6 +119,20 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12)
setCurrentImageIndex(0); setCurrentImageIndex(0);
} }
}, [selectedSection]); }, [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 // Auto-advance carousel for section images
useEffect(() => { useEffect(() => {
...@@ -176,7 +190,10 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12) ...@@ -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', id: '2024_opal',
title: 'Livecoding (Opal Festival 2024)', title: 'Livecoding (Opal Festival 2024)',
...@@ -203,6 +220,19 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12) ...@@ -203,6 +220,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 renderSectionContent = () => {
const sectionContent = sections[selectedSection]; const sectionContent = sections[selectedSection];
...@@ -343,40 +373,40 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12) ...@@ -343,40 +373,40 @@ d4 $ note ("<e3 fs3 <gs3 d4> <a3 df4>>" - 12)
<section id="section1" className={styles.sectionContainer}> <section id="section1" className={styles.sectionContainer}>
<div className={styles.splitSection}> <div className={styles.splitSection}>
<div> <div>
<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')} 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 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> <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> </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>
<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')} 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 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> <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> </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>
<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')} 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 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> <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> </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>
</div> </div>
<div> <div>
{renderSectionContent()} {renderSectionContent()}
</div> </div>
......
...@@ -258,81 +258,75 @@ export default function Live({ data, slug, images }) { ...@@ -258,81 +258,75 @@ export default function Live({ data, slug, images }) {
<div className="grid md:grid-cols-2 gap-6 items-center"> <div className="grid md:grid-cols-2 gap-6 items-center">
{/* Left Side: Event Info */} {/* Left Side: Event Info */}
<div> <div>
<h1 className="text-4xl md:text-5xl font-bold mb-2 text-white">{data.frontmatter.title}</h1> <h1 className="text-4xl md:text-6xl font-bold mb-3 text-white">{data.frontmatter.title}</h1>
{pastEvent ? ( {!pastEvent && (
<div className="inline-block bg-gray-800 text-white text-sm px-3 py-1 rounded-full mb-4"> <h2 className="text-2xl md:text-4xl font-semibold mb-3 text-purple-300">
{timeToEvent === 0 ? "Aujourd'hui !" : `Dans ${timeString}`}
</h2>
)}
{pastEvent && (
<h2 className="text-xl md:text-2xl font-semibold mb-3 text-gray-500">
Événement passé Événement passé
</div> </h2>
) : (
<div className="inline-block bg-purple-800 text-white text-sm px-3 py-1 rounded-full mb-4">
{timeToEvent === 0 ? "Aujourd'hui" : `Dans ${timeString}`}
</div>
)} )}
<div className="flex flex-col space-y-2 mb-4"> <h3 className="text-lg md:text-xl text-gray-400 mb-4">
{data.frontmatter.venue || 'Lieu à annoncer'}
{data.frontmatter.city && `, ${data.frontmatter.city}`}
</h3>
<div className="flex flex-col space-y-1 mb-6 text-sm"> {/* Reduced space-y and mb */}
<div className="flex items-center"> <div className="flex items-center">
<span className="text-purple-400 w-24">Date:</span> <span className="text-purple-400 w-20">Date:</span> {/* Reduced width */}
<span>{formatEventDate(data.frontmatter.date)}</span> <span>{formatEventDate(data.frontmatter.date)}</span>
</div> </div>
<div className="flex items-center">
<span className="text-purple-400 w-24">Lieu:</span>
<span>{data.frontmatter.venue || 'À annoncer'}</span>
</div>
{data.frontmatter.city && (
<div className="flex items-center">
<span className="text-purple-400 w-24">Ville:</span>
<span>{data.frontmatter.city}</span>
</div>
)}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-wrap gap-2 mt-4"> <div className="flex flex-wrap gap-3 mt-6"> {/* Increased gap and mt */}
{data.frontmatter.ticketLink && !pastEvent && ( {data.frontmatter.ticketLink && !pastEvent && (
<a <a
href={data.frontmatter.ticketLink} href={data.frontmatter.ticketLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="bg-gradient-to-r from-purple-600 to-pink-600 px-4 py-2 rounded-md text-white font-medium hover:from-purple-700 hover:to-pink-700 transition-all shadow-lg" className="bg-gradient-to-r from-purple-600 to-pink-600 px-6 py-3 rounded-lg text-white font-semibold hover:from-purple-700 hover:to-pink-700 transition-all shadow-lg hover:shadow-xl text-base" // Increased padding, font-size, rounded
> >
Billets Billets
</a> </a>
)} )}
{data.frontmatter.eventLink && ( {data.frontmatter.eventLink && (
<a <a
href={data.frontmatter.eventLink} href={data.frontmatter.eventLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="bg-gray-800 hover:bg-gray-700 px-4 py-2 rounded-md text-white font-medium transition-all" className="bg-gray-700 hover:bg-gray-600 px-5 py-3 rounded-lg text-white font-medium transition-all shadow-md hover:shadow-lg text-base" // Increased padding, font-size, adjusted colors
> >
Info Event Plus d'Infos
</a> </a>
)} )}
<a <a
href="#details-section" href="#details-section"
onClick={scrollToSection} onClick={scrollToSection}
className="bg-transparent border border-purple-500 hover:bg-purple-900/30 px-4 py-2 rounded-md text-white font-medium transition-all" className="bg-transparent border-2 border-purple-500 hover:bg-purple-500/20 px-5 py-3 rounded-lg text-purple-300 hover:text-white font-medium transition-all text-base" // Increased padding, font-size, border
> >
Détails Voir les Détails
</a> </a>
</div> </div>
</div> </div>
{/* Right Side: Upcoming Drops or Latest Teasing */} {/* Right Side: Upcoming Drops or Latest Teasing */}
<div> <div className="mt-6 md:mt-0"> {/* Added margin top for small screens */}
{!pastEvent && upcomingDrops.length > 0 ? ( {!pastEvent && upcomingDrops.length > 0 ? (
<div className="bg-black/60 backdrop-blur p-4 rounded-lg border border-purple-500/30"> <div className="bg-black/70 backdrop-blur-md p-6 rounded-xl border border-purple-500/40 shadow-xl"> {/* Enhanced styling */}
<h3 className="text-lg font-semibold text-purple-400 mb-3">Prochains drops</h3> <h3 className="text-xl font-bold text-purple-300 mb-4">Prochains Drops</h3> {/* Enhanced styling */}
<ul className="space-y-2"> <ul className="space-y-3">
{upcomingDrops.map((drop, i) => ( {upcomingDrops.map((drop, i) => (
<li key={i} className="flex items-center justify-between bg-black/50 p-2 rounded"> <li key={i} className="flex items-center justify-between bg-gray-800/70 p-3 rounded-md shadow"> {/* Enhanced styling */}
<span className="text-sm">{drop.name}</span> <span className="text-sm font-medium text-gray-200">{drop.name}</span>
<span className="text-xs bg-purple-900/60 px-2 py-1 rounded"> <span className="text-xs bg-purple-700/80 text-white px-3 py-1 rounded-full font-semibold"> {/* Enhanced styling */}
{getTimeDifferenceString(drop.date)} {getTimeDifferenceString(drop.date)}
</span> </span>
</li> </li>
...@@ -340,13 +334,17 @@ export default function Live({ data, slug, images }) { ...@@ -340,13 +334,17 @@ export default function Live({ data, slug, images }) {
</ul> </ul>
</div> </div>
) : teasingsToShow.length > 0 ? ( ) : teasingsToShow.length > 0 ? (
<div className="bg-black/60 backdrop-blur p-4 rounded-lg border border-purple-500/30"> <div className="bg-black/70 backdrop-blur-md p-6 rounded-xl border border-purple-500/40 shadow-xl"> {/* Enhanced styling */}
<h3 className="text-lg font-semibold text-purple-400 mb-2"> <h3 className="text-xl font-bold text-purple-300 mb-3"> {/* Enhanced styling */}
{pastEvent ? "Highlights" : "Teasing"} {pastEvent ? "Highlights" : "Dernier Teasing"}
</h3> </h3>
<div dangerouslySetInnerHTML={renderMarkdown(teasingsToShow[0])} className="prose prose-sm prose-invert max-w-none" /> <div dangerouslySetInnerHTML={renderMarkdown(teasingsToShow[0])} className="prose prose-base prose-invert max-w-none leading-relaxed" /> {/* Increased prose size, leading */}
</div> </div>
) : null} ) : (
<div className="bg-black/70 backdrop-blur-md p-6 rounded-xl border border-purple-500/40 shadow-xl text-gray-400">
<p>Plus de détails à venir prochainement...</p>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
...@@ -418,44 +416,13 @@ export default function Live({ data, slug, images }) { ...@@ -418,44 +416,13 @@ export default function Live({ data, slug, images }) {
/> />
</div> </div>
)} )}
{/* Artists / Other sidebar content can go here if needed */}
{/* Mini Gallery - Limited to 3 images */}
{images && images.length > 0 && (
<div className="bg-gray-900/40 rounded-lg p-4">
<h3 className="text-lg font-semibold text-purple-400 mb-2">Photos</h3>
<div className="grid grid-cols-3 gap-2">
{images.slice(0, 3).map((image, i) => (
<div key={i} className="relative aspect-square rounded overflow-hidden">
<img
src={image}
alt={`${data.frontmatter.title} - image ${i+1}`}
fill
className="live-gallery-image object-cover hover:scale-110 transition-transform duration-300 max-w-sm"
/>
</div>
))}
</div>
{images.length > 3 && (
<div className="mt-2 text-center">
<button
onClick={() => {
const gallery = document.getElementById('full-gallery');
gallery?.scrollIntoView({ behavior: 'smooth' });
}}
className="text-xs text-purple-400 hover:text-purple-300"
>
Voir les {images.length} photos →
</button>
</div>
)}
</div>
)}
</div> </div>
</div> </div>
{/* Full Gallery - only if more than 3 images */} {/* Full Image Gallery - Render if images exist */}
{images && images.length > 3 && ( {images && images.length > 0 && (
<div id="full-gallery" className="max-w-5xl mx-auto mt-8"> <div id="full-gallery" className="max-w-6xl mx-auto mt-12 pt-8 border-t border-purple-500/20"> {/* Increased max-width and added top margin/padding/border */}
<ImageGallery images={images} slug={slug} /> <ImageGallery images={images} slug={slug} />
</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 @@ ...@@ -168,13 +168,14 @@
box-shadow: 0 10px 20px rgba(217, 0, 255, 0.3); box-shadow: 0 10px 20px rgba(217, 0, 255, 0.3);
} }
/* This is the .outlineButton style used by the header's Book button */
.outlineButton { .outlineButton {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 1rem 2rem; /* padding is controlled by Tailwind classes in the component (py-2 px-3 sm:px-4) */
border-radius: 0.5rem; border-radius: 0.375rem; /* Tailwind's rounded-md */
font-weight: 600; font-weight: 500; /* Tailwind's font-medium equivalent */
text-decoration: none; text-decoration: none;
color: white; color: white;
border: 1px solid rgba(217, 0, 255, 0.5); border: 1px solid rgba(217, 0, 255, 0.5);
...@@ -188,8 +189,8 @@ ...@@ -188,8 +189,8 @@
background: rgba(217, 0, 255, 0.2); background: rgba(217, 0, 255, 0.2);
border-color: var(--neon-high); border-color: var(--neon-high);
color: var(--neon-high); color: var(--neon-high);
transform: translateY(-2px); transform: translateY(-1px); /* Subtle lift */
box-shadow: 0 5px 15px rgba(217, 0, 255, 0.2); box-shadow: 0 3px 10px rgba(217, 0, 255, 0.2); /* Subtle shadow */
} }
.sectionContainer { .sectionContainer {
...@@ -562,5 +563,106 @@ AS MODULE CSS FORBIDS ROOT VARIABLES ...@@ -562,5 +563,106 @@ AS MODULE CSS FORBIDS ROOT VARIABLES
} }
img.live-gallery-image { 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