Commit fbdcaf0f by PLN (Algolia)

Dunbar: v0.1

parent 40fb459c
......@@ -31,3 +31,4 @@ yarn-error.log*
# LLM exchanges
code2prompt.json
.vercel
......@@ -4,6 +4,8 @@ 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 });
......@@ -43,6 +45,7 @@ function Tabs({ tab, setTab }) {
}
export default function DunbarApp() {
const router = useRouter();
const { state, friends, selectedFriendId, actions, derived } = useDunbarStore();
const [tab, setTab] = useState('friends');
const [authed, setAuthed] = useState(false);
......@@ -122,9 +125,61 @@ export default function DunbarApp() {
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 });
};
if (!authed) {
// 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]);
return (
<div className={styles.lockWrap}>
<h2 className={styles.title}>Dunbar</h2>
......@@ -181,6 +236,7 @@ export default function DunbarApp() {
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)}
......@@ -195,6 +251,7 @@ export default function DunbarApp() {
<SearchTab
friends={friends}
openFriend={openFriendDetail}
openEvent={openEventDetail}
/>
)}
......@@ -202,7 +259,10 @@ export default function DunbarApp() {
<EventsTab
friends={friends}
addEvent={(payload) => actions.addEvent(payload)}
updateEvent={(id, patch) => actions.updateEvent(id, patch)}
selectedEventId={state.selectedEventId}
eventIndex={derived.eventIndex}
openEvent={openEventDetail}
/>
)}
......@@ -223,7 +283,7 @@ export default function DunbarApp() {
)}
{tab === 'stats' && (
<StatsTab stats={derived.stats} anniversaries={derived.anniversaries} />
<StatsTab stats={derived.stats} anniversaries={derived.anniversaries} openFriend={openFriendDetail} />
)}
</div>
);
......
......@@ -10,11 +10,12 @@ import {
} from '@/lib/dunbar';
import { extractTags } from '@/lib/dunbar';
export default function EventsTab({ friends, addEvent, eventIndex }) {
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());
......@@ -38,13 +39,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
};
const selectedCount = selected.size;
const canCreate = selectedCount > 0 && notes.trim().length > 0;
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),
......@@ -56,6 +58,56 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
// Timeline groups from merged eventIndex
const groups = useMemo(() => groupEventsByDay(eventIndex), [eventIndex]);
// 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;
......@@ -110,6 +162,15 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
/>
</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)"
......@@ -156,9 +217,83 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
</div>
</div>
{/* Timeline */}
{/* Timeline + Editor */}
<div className={styles.card}>
<div className={styles.cardHeader}>Timeline</div>
{/* 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}>
......@@ -169,7 +304,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
.filter(Boolean);
return (
<div key={e.id + e.date} className={styles.timelineEvent}>
<div><strong>{names.join(', ') || 'Unknown'}</strong></div>
<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>
......
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import { isoDate, sortEventsDesc, extractTags } from '@/lib/dunbar';
import { isoDate, sortEventsDesc, extractTags, eventSlug } from '@/lib/dunbar';
export default function FriendDetail({
......@@ -16,6 +16,7 @@ export default function FriendDetail({
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);
......@@ -192,15 +193,18 @@ export default function FriendDetail({
if (!friend) return;
const dateISO = new Date(date).toISOString();
const n = notes.trim();
if (!dateISO || !n) return;
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 only; keep date for faster entry
// reset notes/title only; keep date for faster entry
setNotes('');
setTitle('');
};
if (!friend) {
......@@ -434,6 +438,15 @@ export default function FriendDetail({
/>
</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)"
......@@ -443,7 +456,7 @@ export default function FriendDetail({
/>
</div>
<div className={styles.row} style={{ marginBottom: 12 }}>
<button className={styles.btn} onClick={submitEvent} disabled={!notes.trim()}>
<button className={styles.btn} onClick={submitEvent} disabled={!title.trim() || !notes.trim()}>
Add Event
</button>
</div>
......@@ -455,6 +468,13 @@ export default function FriendDetail({
<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>
......
......@@ -8,7 +8,7 @@ import {
suggestPersons,
} from '@/lib/dunbar-search';
export default function SearchTab({ friends, openFriend }) {
export default function SearchTab({ friends, openFriend, openEvent }) {
const [q, setQ] = useState('');
const [includeTags, setIncludeTags] = useState(new Set());
const [excludeTags, setExcludeTags] = useState(new Set());
......@@ -183,6 +183,13 @@ export default function SearchTab({ friends, openFriend }) {
{(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"
>
{e.title || '(untitled)'}
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>
{renderNotesWithTags(e.notes || '')}
</div>
......
......@@ -2,7 +2,7 @@ import React from 'react';
import styles from '@/styles/dunbar.module.css';
import { isoDate } from '@/lib/dunbar';
export default function StatsTab({ stats, anniversaries = [] }) {
export default function StatsTab({ stats, anniversaries = [], openFriend }) {
if (!stats) return null;
const items = [
{ label: 'Connections', value: stats.connections },
......@@ -44,7 +44,13 @@ export default function StatsTab({ stats, anniversaries = [] }) {
<div className={styles.timelineDate}>{day}</div>
{items.map((it, idx) => (
<div key={day + '-' + idx} className={styles.timelineEvent}>
<div style={{ fontWeight: 700 }}>{it.friendName}</div>
<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) ? (
......
......@@ -43,6 +43,7 @@ function computeLastInteraction(friend) {
const initialState = {
friends: [],
selectedFriendId: null,
selectedEventId: null,
};
function reducer(state, action) {
......@@ -59,16 +60,17 @@ function reducer(state, action) {
birthday: null,
notes: '',
// rich profile fields
likes: '',
dislikes: '',
likes: [],
dislikes: [],
foodLikes: '',
foodDislikes: '',
wifiPassword: '',
carModel: '',
workplace: '',
schedule: '',
futureIdeas: '',
quotes: '',
futureIdeas: [],
quotes: [],
projects: [],
importantDates: [], // [{ date: 'YYYY-MM-DD', label: string }]
gifts: [], // [{ date, occasion, description, image }]
postcards: [], // [{ date, location, description, image }]
......@@ -122,6 +124,10 @@ function reducer(state, action) {
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;
......@@ -142,10 +148,10 @@ function reducer(state, action) {
return { ...state, friends };
}
case 'ADD_EVENT': {
const { date, notes, participants = [], location } = action.payload || {};
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 || !notes || !participants.length) return state;
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
......@@ -154,6 +160,7 @@ function reducer(state, action) {
const ev = {
id: eventId,
date: dateStr,
title: String(title),
notes: String(notes),
location: location ? String(location) : undefined,
participants: [...participants],
......@@ -164,6 +171,65 @@ function reducer(state, action) {
}
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': {
......@@ -224,6 +290,8 @@ export function useDunbarStore() {
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?');
......@@ -377,6 +445,7 @@ export function useDunbarStore() {
state,
friends: state.friends,
selectedFriendId: state.selectedFriendId,
selectedEventId: state.selectedEventId,
actions: {
addFriend,
removeFriend,
......@@ -387,6 +456,8 @@ export function useDunbarStore() {
selectFriend,
toggleRelationship,
addEvent,
selectEvent,
updateEvent,
resetData,
loadFromPayload,
},
......
......@@ -12,6 +12,18 @@ export const twitterHandle = "@PaulLouisNech";
export const description = "PLN's Selected Works";
export default function Layout({ children, home }) {
// Simple feedback launcher: prompts for text then opens default mail client
const handleFeedbackMail = () => {
try {
const txt = typeof window !== 'undefined' ? window.prompt('Feedback for Dunbar (will open your email client):', '') : '';
const subject = 'Dunbar feedback';
const url = typeof window !== 'undefined' ? window.location.href : '';
const body = `${txt ? txt + '\\n\\n' : ''}From: ${url}`;
const mailto = `mailto:dunbar@nech.pl?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
if (typeof window !== 'undefined') window.location.href = mailto;
} catch {}
};
return (
<div className={styles.container}>
<Head>
......@@ -86,6 +98,16 @@ export default function Layout({ children, home }) {
>
</a>
{' '}|{' '}
<button
type="button"
onClick={handleFeedbackMail}
className={utilStyles.backButton}
style={{ cursor: 'pointer', border: 'none', background: 'transparent', padding: 0 }}
title="Send feedback about Dunbar"
>
Feedback (dunbar@nech.pl)
</button>
</footer>
</div>
);
......
......@@ -67,24 +67,33 @@ export function buildSearchData(friends = []) {
// Aggregate tags from events + friend notes + rich profile
const aggTags = new Set();
const addTagsFrom = (txt) => {
for (const t of extractTagsFromText(txt || '')) aggTags.add(t);
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);
}
};
addTagsFrom(f.notes);
addTagsFrom(f.likes);
addTagsFrom(f.dislikes);
addTagsFrom(f.foodLikes);
addTagsFrom(f.foodDislikes);
addTagsFrom(f.futureIdeas);
addTagsFrom(f.quotes);
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 || []) addTagsFrom(ev.notes);
for (const ev of f.events || []) {
addTagsFromAny(ev.notes);
addTagsFromAny(ev.title);
}
// Compose an extended notes blob to improve recall
const profileBlob = [
f.notes,
f.likes,
f.dislikes,
Array.isArray(f.likes) ? f.likes.join(', ') : f.likes,
Array.isArray(f.dislikes) ? f.dislikes.join(', ') : f.dislikes,
f.foodLikes,
f.foodDislikes,
f.futureIdeas,
......@@ -94,7 +103,7 @@ export function buildSearchData(friends = []) {
f.carModel,
]
.filter(Boolean)
.join(' \n');
.join(' \\n');
const friendDoc = {
id: `friend:${f.id}`,
......@@ -138,6 +147,7 @@ export function buildSearchData(friends = []) {
id: `event:${e.id}`,
kind: 'event',
refId: e.id,
title: e.title || '',
notes: e.notes || '',
location: e.location || '',
tags,
......@@ -188,9 +198,9 @@ export function buildSearchIndexes(friends = []) {
});
const eventIndex = makeMiniSearch(
eventDocs,
['notes', 'location', 'tags', 'participantNames'],
['id', 'kind', 'refId', 'tags', 'participantNames', 'date'],
{ tags: 2, participantNames: 1.5 }
['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 };
......
......@@ -73,6 +73,41 @@ export function extractTags(text = '') {
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);
......@@ -209,16 +244,17 @@ export function makeExportPayload(state) {
birthday: f.birthday || null,
notes: f.notes || '',
// rich profile
likes: f.likes || '',
dislikes: f.dislikes || '',
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: f.futureIdeas || '',
quotes: f.quotes || '',
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 || '',
......@@ -239,6 +275,7 @@ export function makeExportPayload(state) {
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] : [],
......@@ -263,16 +300,17 @@ export function normalizeImportedPayload(payload) {
birthday: f.birthday || null,
notes: f.notes || '',
// rich profile (defaults)
likes: f.likes || '',
dislikes: f.dislikes || '',
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: f.futureIdeas || '',
quotes: f.quotes || '',
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 || '',
......@@ -293,6 +331,7 @@ export function normalizeImportedPayload(payload) {
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 : [],
......
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>
);
}
......@@ -9,6 +9,22 @@ export default function DunbarPage() {
<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>
......
......@@ -250,6 +250,17 @@
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 {
......@@ -399,6 +410,11 @@
justify-content: center;
font-weight: 700;
color: #333;
transition: transform 80ms ease, background 120ms ease;
}
.ctrlBtn:active {
transform: scale(0.96);
background: #f6f6f6;
}
.ctrlWide {
......
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