Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
W
www
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
1
Issues
1
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
PLN
www
Commits
fbdcaf0f
Commit
fbdcaf0f
authored
Sep 13, 2025
by
PLN (Algolia)
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Dunbar: v0.1
parent
40fb459c
Show whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
480 additions
and
42 deletions
+480
-42
.gitignore
next/.gitignore
+1
-0
DunbarApp.js
next/components/dunbar/DunbarApp.js
+61
-1
EventsTab.js
next/components/dunbar/EventsTab.js
+146
-4
FriendDetail.js
next/components/dunbar/FriendDetail.js
+24
-4
SearchTab.js
next/components/dunbar/SearchTab.js
+8
-1
StatsTab.js
next/components/dunbar/StatsTab.js
+8
-2
useDunbarStore.js
next/components/dunbar/useDunbarStore.js
+77
-6
layout.js
next/components/layout.js
+22
-0
dunbar-search.js
next/lib/dunbar-search.js
+26
-16
dunbar.js
next/lib/dunbar.js
+47
-8
[slug].js
next/pages/dunbar/event/[slug].js
+28
-0
index.js
next/pages/dunbar/index.js
+16
-0
dunbar.module.css
next/styles/dunbar.module.css
+16
-0
No files found.
next/.gitignore
View file @
fbdcaf0f
...
@@ -31,3 +31,4 @@ yarn-error.log*
...
@@ -31,3 +31,4 @@ yarn-error.log*
# LLM exchanges
# LLM exchanges
code2prompt.json
code2prompt.json
.vercel
next/components/dunbar/DunbarApp.js
View file @
fbdcaf0f
...
@@ -4,6 +4,8 @@ import { useDunbarStore } from '@/components/dunbar/useDunbarStore';
...
@@ -4,6 +4,8 @@ import { useDunbarStore } from '@/components/dunbar/useDunbarStore';
import
dynamic
from
'next/dynamic'
;
import
dynamic
from
'next/dynamic'
;
import
{
makeExportPayload
}
from
'@/lib/dunbar'
;
import
{
makeExportPayload
}
from
'@/lib/dunbar'
;
import
{
generateDemoPayload
}
from
'@/lib/dunbar-demo'
;
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)
// Lazy-load heavy tabs if needed (Network uses d3)
const
NetworkTab
=
dynamic
(()
=>
import
(
'@/components/dunbar/NetworkTab'
),
{
ssr
:
false
});
const
NetworkTab
=
dynamic
(()
=>
import
(
'@/components/dunbar/NetworkTab'
),
{
ssr
:
false
});
...
@@ -43,6 +45,7 @@ function Tabs({ tab, setTab }) {
...
@@ -43,6 +45,7 @@ function Tabs({ tab, setTab }) {
}
}
export
default
function
DunbarApp
()
{
export
default
function
DunbarApp
()
{
const
router
=
useRouter
();
const
{
state
,
friends
,
selectedFriendId
,
actions
,
derived
}
=
useDunbarStore
();
const
{
state
,
friends
,
selectedFriendId
,
actions
,
derived
}
=
useDunbarStore
();
const
[
tab
,
setTab
]
=
useState
(
'friends'
);
const
[
tab
,
setTab
]
=
useState
(
'friends'
);
const
[
authed
,
setAuthed
]
=
useState
(
false
);
const
[
authed
,
setAuthed
]
=
useState
(
false
);
...
@@ -122,9 +125,61 @@ export default function DunbarApp() {
...
@@ -122,9 +125,61 @@ export default function DunbarApp() {
const
openFriendDetail
=
(
friendId
)
=>
{
const
openFriendDetail
=
(
friendId
)
=>
{
actions
.
selectFriend
(
friendId
);
actions
.
selectFriend
(
friendId
);
setTab
(
'friends'
);
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
)
{
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
(
return
(
<
div
className
=
{
styles
.
lockWrap
}
>
<
div
className
=
{
styles
.
lockWrap
}
>
<
h2
className
=
{
styles
.
title
}
>
Dunbar
<
/h2
>
<
h2
className
=
{
styles
.
title
}
>
Dunbar
<
/h2
>
...
@@ -181,6 +236,7 @@ export default function DunbarApp() {
...
@@ -181,6 +236,7 @@ export default function DunbarApp() {
friends
=
{
friends
}
friends
=
{
friends
}
onToggleRel
=
{(
a
,
b
)
=>
actions
.
toggleRelationship
(
a
,
b
)}
onToggleRel
=
{(
a
,
b
)
=>
actions
.
toggleRelationship
(
a
,
b
)}
onAddEvent
=
{(
payload
)
=>
actions
.
addEvent
(
payload
)}
onAddEvent
=
{(
payload
)
=>
actions
.
addEvent
(
payload
)}
onUpdateEvent
=
{(
id
,
patch
)
=>
actions
.
updateEvent
(
id
,
patch
)}
onRename
=
{(
id
,
name
)
=>
actions
.
renameFriend
(
id
,
name
)}
onRename
=
{(
id
,
name
)
=>
actions
.
renameFriend
(
id
,
name
)}
onSetBirthday
=
{(
id
,
ymd
)
=>
actions
.
setBirthday
(
id
,
ymd
)}
onSetBirthday
=
{(
id
,
ymd
)
=>
actions
.
setBirthday
(
id
,
ymd
)}
onSetNotes
=
{(
id
,
notes
)
=>
actions
.
setFriendNotes
(
id
,
notes
)}
onSetNotes
=
{(
id
,
notes
)
=>
actions
.
setFriendNotes
(
id
,
notes
)}
...
@@ -195,6 +251,7 @@ export default function DunbarApp() {
...
@@ -195,6 +251,7 @@ export default function DunbarApp() {
<
SearchTab
<
SearchTab
friends
=
{
friends
}
friends
=
{
friends
}
openFriend
=
{
openFriendDetail
}
openFriend
=
{
openFriendDetail
}
openEvent
=
{
openEventDetail
}
/
>
/
>
)}
)}
...
@@ -202,7 +259,10 @@ export default function DunbarApp() {
...
@@ -202,7 +259,10 @@ export default function DunbarApp() {
<
EventsTab
<
EventsTab
friends
=
{
friends
}
friends
=
{
friends
}
addEvent
=
{(
payload
)
=>
actions
.
addEvent
(
payload
)}
addEvent
=
{(
payload
)
=>
actions
.
addEvent
(
payload
)}
updateEvent
=
{(
id
,
patch
)
=>
actions
.
updateEvent
(
id
,
patch
)}
selectedEventId
=
{
state
.
selectedEventId
}
eventIndex
=
{
derived
.
eventIndex
}
eventIndex
=
{
derived
.
eventIndex
}
openEvent
=
{
openEventDetail
}
/
>
/
>
)}
)}
...
@@ -223,7 +283,7 @@ export default function DunbarApp() {
...
@@ -223,7 +283,7 @@ export default function DunbarApp() {
)}
)}
{
tab
===
'stats'
&&
(
{
tab
===
'stats'
&&
(
<
StatsTab
stats
=
{
derived
.
stats
}
anniversaries
=
{
derived
.
anniversaries
}
/
>
<
StatsTab
stats
=
{
derived
.
stats
}
anniversaries
=
{
derived
.
anniversaries
}
openFriend
=
{
openFriendDetail
}
/
>
)}
)}
<
/div
>
<
/div
>
);
);
...
...
next/components/dunbar/EventsTab.js
View file @
fbdcaf0f
...
@@ -10,11 +10,12 @@ import {
...
@@ -10,11 +10,12 @@ import {
}
from
'@/lib/dunbar'
;
}
from
'@/lib/dunbar'
;
import
{
extractTags
}
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
// Creation form state
const
[
date
,
setDate
]
=
useState
(
todayISO
());
const
[
date
,
setDate
]
=
useState
(
todayISO
());
const
[
notes
,
setNotes
]
=
useState
(
''
);
const
[
notes
,
setNotes
]
=
useState
(
''
);
const
[
location
,
setLocation
]
=
useState
(
''
);
const
[
location
,
setLocation
]
=
useState
(
''
);
const
[
title
,
setTitle
]
=
useState
(
''
);
const
[
filter
,
setFilter
]
=
useState
(
''
);
const
[
filter
,
setFilter
]
=
useState
(
''
);
const
[
selected
,
setSelected
]
=
useState
(()
=>
new
Set
());
const
[
selected
,
setSelected
]
=
useState
(()
=>
new
Set
());
...
@@ -38,13 +39,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
...
@@ -38,13 +39,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
};
};
const
selectedCount
=
selected
.
size
;
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
=
()
=>
{
const
createEvent
=
()
=>
{
if
(
!
canCreate
)
return
;
if
(
!
canCreate
)
return
;
const
dateISO
=
new
Date
(
date
).
toISOString
();
const
dateISO
=
new
Date
(
date
).
toISOString
();
addEvent
({
addEvent
({
date
:
dateISO
,
date
:
dateISO
,
title
:
title
.
trim
(),
notes
:
notes
.
trim
(),
notes
:
notes
.
trim
(),
location
:
location
.
trim
()
||
undefined
,
location
:
location
.
trim
()
||
undefined
,
participants
:
Array
.
from
(
selected
),
participants
:
Array
.
from
(
selected
),
...
@@ -56,6 +58,56 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
...
@@ -56,6 +58,56 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
// Timeline groups from merged eventIndex
// Timeline groups from merged eventIndex
const
groups
=
useMemo
(()
=>
groupEventsByDay
(
eventIndex
),
[
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
// Render notes with inline #tags highlighted
const
renderNotesWithTags
=
(
text
=
''
)
=>
{
const
renderNotesWithTags
=
(
text
=
''
)
=>
{
const
re
=
/
(
#
([\p
{L}
\p
{N}_-
]
+
))
/g
u
;
const
re
=
/
(
#
([\p
{L}
\p
{N}_-
]
+
))
/g
u
;
...
@@ -110,6 +162,15 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
...
@@ -110,6 +162,15 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
/
>
/
>
<
/div
>
<
/div
>
<
div
className
=
{
styles
.
row
}
style
=
{{
marginBottom
:
8
}}
>
<
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
<
textarea
className
=
{
styles
.
textarea
}
className
=
{
styles
.
textarea
}
placeholder
=
"Notes (required)"
placeholder
=
"Notes (required)"
...
@@ -156,9 +217,83 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
...
@@ -156,9 +217,83 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
<
/div
>
<
/div
>
<
/div
>
<
/div
>
{
/* Timeline */
}
{
/* Timeline
+ Editor
*/
}
<
div
className
=
{
styles
.
card
}
>
<
div
className
=
{
styles
.
card
}
>
<
div
className
=
{
styles
.
cardHeader
}
>
Timeline
<
/div
>
<
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
}
>
<
div
className
=
{
styles
.
timeline
}
>
{
groups
.
map
((
g
)
=>
(
{
groups
.
map
((
g
)
=>
(
<
div
key
=
{
g
.
dateKey
}
className
=
{
styles
.
timelineGroup
}
>
<
div
key
=
{
g
.
dateKey
}
className
=
{
styles
.
timelineGroup
}
>
...
@@ -169,7 +304,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
...
@@ -169,7 +304,14 @@ export default function EventsTab({ friends, addEvent, eventIndex }) {
.
filter
(
Boolean
);
.
filter
(
Boolean
);
return
(
return
(
<
div
key
=
{
e
.
id
+
e
.
date
}
className
=
{
styles
.
timelineEvent
}
>
<
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'
}}
>
<
div
style
=
{{
whiteSpace
:
'pre-wrap'
}}
>
{
renderNotesWithTags
(
e
.
notes
||
''
)}
{
renderNotesWithTags
(
e
.
notes
||
''
)}
<
/div
>
<
/div
>
...
...
next/components/dunbar/FriendDetail.js
View file @
fbdcaf0f
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
styles
from
'@/styles/dunbar.module.css'
;
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
({
export
default
function
FriendDetail
({
...
@@ -16,6 +16,7 @@ export default function FriendDetail({
...
@@ -16,6 +16,7 @@ export default function FriendDetail({
const
[
date
,
setDate
]
=
useState
(()
=>
new
Date
().
toISOString
().
slice
(
0
,
10
));
const
[
date
,
setDate
]
=
useState
(()
=>
new
Date
().
toISOString
().
slice
(
0
,
10
));
const
[
notes
,
setNotes
]
=
useState
(
''
);
const
[
notes
,
setNotes
]
=
useState
(
''
);
const
[
location
,
setLocation
]
=
useState
(
''
);
const
[
location
,
setLocation
]
=
useState
(
''
);
const
[
title
,
setTitle
]
=
useState
(
''
);
const
[
friendNotes
,
setFriendNotes
]
=
useState
(
''
);
const
[
friendNotes
,
setFriendNotes
]
=
useState
(
''
);
const
[
birthday
,
setBirthday
]
=
useState
(
''
);
const
[
birthday
,
setBirthday
]
=
useState
(
''
);
const
relScrollRef
=
useRef
(
null
);
const
relScrollRef
=
useRef
(
null
);
...
@@ -192,15 +193,18 @@ export default function FriendDetail({
...
@@ -192,15 +193,18 @@ export default function FriendDetail({
if
(
!
friend
)
return
;
if
(
!
friend
)
return
;
const
dateISO
=
new
Date
(
date
).
toISOString
();
const
dateISO
=
new
Date
(
date
).
toISOString
();
const
n
=
notes
.
trim
();
const
n
=
notes
.
trim
();
if
(
!
dateISO
||
!
n
)
return
;
const
t
=
title
.
trim
();
if
(
!
dateISO
||
!
t
||
!
n
)
return
;
onAddEvent
?.({
onAddEvent
?.({
date
:
dateISO
,
date
:
dateISO
,
title
:
t
,
notes
:
n
,
notes
:
n
,
location
:
location
.
trim
()
||
undefined
,
location
:
location
.
trim
()
||
undefined
,
participants
:
[
friend
.
id
],
participants
:
[
friend
.
id
],
});
});
// reset notes only; keep date for faster entry
// reset notes
/title
only; keep date for faster entry
setNotes
(
''
);
setNotes
(
''
);
setTitle
(
''
);
};
};
if
(
!
friend
)
{
if
(
!
friend
)
{
...
@@ -434,6 +438,15 @@ export default function FriendDetail({
...
@@ -434,6 +438,15 @@ export default function FriendDetail({
/
>
/
>
<
/div
>
<
/div
>
<
div
className
=
{
styles
.
row
}
style
=
{{
marginBottom
:
8
}}
>
<
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
<
textarea
className
=
{
styles
.
textarea
}
className
=
{
styles
.
textarea
}
placeholder
=
"Notes (required)"
placeholder
=
"Notes (required)"
...
@@ -443,7 +456,7 @@ export default function FriendDetail({
...
@@ -443,7 +456,7 @@ export default function FriendDetail({
/
>
/
>
<
/div
>
<
/div
>
<
div
className
=
{
styles
.
row
}
style
=
{{
marginBottom
:
12
}}
>
<
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
Add
Event
<
/button
>
<
/button
>
<
/div
>
<
/div
>
...
@@ -455,6 +468,13 @@ export default function FriendDetail({
...
@@ -455,6 +468,13 @@ export default function FriendDetail({
<
div
className
=
{
styles
.
timelineDate
}
>
<
div
className
=
{
styles
.
timelineDate
}
>
{
isoDate
(
e
.
date
)}
{
isoDate
(
e
.
date
)}
<
/div
>
<
/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'
}}
>
<
div
style
=
{{
whiteSpace
:
'pre-wrap'
}}
>
{
renderNotesWithTags
(
e
.
notes
||
''
)}
{
renderNotesWithTags
(
e
.
notes
||
''
)}
<
/div
>
<
/div
>
...
...
next/components/dunbar/SearchTab.js
View file @
fbdcaf0f
...
@@ -8,7 +8,7 @@ import {
...
@@ -8,7 +8,7 @@ import {
suggestPersons
,
suggestPersons
,
}
from
'@/lib/dunbar-search'
;
}
from
'@/lib/dunbar-search'
;
export
default
function
SearchTab
({
friends
,
openFriend
})
{
export
default
function
SearchTab
({
friends
,
openFriend
,
openEvent
})
{
const
[
q
,
setQ
]
=
useState
(
''
);
const
[
q
,
setQ
]
=
useState
(
''
);
const
[
includeTags
,
setIncludeTags
]
=
useState
(
new
Set
());
const
[
includeTags
,
setIncludeTags
]
=
useState
(
new
Set
());
const
[
excludeTags
,
setExcludeTags
]
=
useState
(
new
Set
());
const
[
excludeTags
,
setExcludeTags
]
=
useState
(
new
Set
());
...
@@ -183,6 +183,13 @@ export default function SearchTab({ friends, openFriend }) {
...
@@ -183,6 +183,13 @@ export default function SearchTab({ friends, openFriend }) {
{(
results
.
events
||
[]).
map
((
e
)
=>
(
{(
results
.
events
||
[]).
map
((
e
)
=>
(
<
div
key
=
{
e
.
id
}
className
=
{
styles
.
timelineEvent
}
>
<
div
key
=
{
e
.
id
}
className
=
{
styles
.
timelineEvent
}
>
<
div
className
=
{
styles
.
timelineDate
}
>
{
e
.
date
}
<
/div
>
<
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'
}}
>
<
div
style
=
{{
whiteSpace
:
'pre-wrap'
}}
>
{
renderNotesWithTags
(
e
.
notes
||
''
)}
{
renderNotesWithTags
(
e
.
notes
||
''
)}
<
/div
>
<
/div
>
...
...
next/components/dunbar/StatsTab.js
View file @
fbdcaf0f
...
@@ -2,7 +2,7 @@ import React from 'react';
...
@@ -2,7 +2,7 @@ import React from 'react';
import
styles
from
'@/styles/dunbar.module.css'
;
import
styles
from
'@/styles/dunbar.module.css'
;
import
{
isoDate
}
from
'@/lib/dunbar'
;
import
{
isoDate
}
from
'@/lib/dunbar'
;
export
default
function
StatsTab
({
stats
,
anniversaries
=
[]
})
{
export
default
function
StatsTab
({
stats
,
anniversaries
=
[]
,
openFriend
})
{
if
(
!
stats
)
return
null
;
if
(
!
stats
)
return
null
;
const
items
=
[
const
items
=
[
{
label
:
'Connections'
,
value
:
stats
.
connections
},
{
label
:
'Connections'
,
value
:
stats
.
connections
},
...
@@ -44,7 +44,13 @@ export default function StatsTab({ stats, anniversaries = [] }) {
...
@@ -44,7 +44,13 @@ export default function StatsTab({ stats, anniversaries = [] }) {
<
div
className
=
{
styles
.
timelineDate
}
>
{
day
}
<
/div
>
<
div
className
=
{
styles
.
timelineDate
}
>
{
day
}
<
/div
>
{
items
.
map
((
it
,
idx
)
=>
(
{
items
.
map
((
it
,
idx
)
=>
(
<
div
key
=
{
day
+
'-'
+
idx
}
className
=
{
styles
.
timelineEvent
}
>
<
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
>
<
div
style
=
{{
color
:
'#555'
}}
>
{
it
.
label
}
<
/div
>
{
/* Anchor event preview if provided */
}
{
/* Anchor event preview if provided */
}
{
it
.
anchorTitle
||
(
it
.
anchorTags
&&
it
.
anchorTags
.
length
>
0
)
?
(
{
it
.
anchorTitle
||
(
it
.
anchorTags
&&
it
.
anchorTags
.
length
>
0
)
?
(
...
...
next/components/dunbar/useDunbarStore.js
View file @
fbdcaf0f
...
@@ -43,6 +43,7 @@ function computeLastInteraction(friend) {
...
@@ -43,6 +43,7 @@ function computeLastInteraction(friend) {
const
initialState
=
{
const
initialState
=
{
friends
:
[],
friends
:
[],
selectedFriendId
:
null
,
selectedFriendId
:
null
,
selectedEventId
:
null
,
};
};
function
reducer
(
state
,
action
)
{
function
reducer
(
state
,
action
)
{
...
@@ -59,16 +60,17 @@ function reducer(state, action) {
...
@@ -59,16 +60,17 @@ function reducer(state, action) {
birthday
:
null
,
birthday
:
null
,
notes
:
''
,
notes
:
''
,
// rich profile fields
// rich profile fields
likes
:
''
,
likes
:
[]
,
dislikes
:
''
,
dislikes
:
[]
,
foodLikes
:
''
,
foodLikes
:
''
,
foodDislikes
:
''
,
foodDislikes
:
''
,
wifiPassword
:
''
,
wifiPassword
:
''
,
carModel
:
''
,
carModel
:
''
,
workplace
:
''
,
workplace
:
''
,
schedule
:
''
,
schedule
:
''
,
futureIdeas
:
''
,
futureIdeas
:
[],
quotes
:
''
,
quotes
:
[],
projects
:
[],
importantDates
:
[],
// [{ date: 'YYYY-MM-DD', label: string }]
importantDates
:
[],
// [{ date: 'YYYY-MM-DD', label: string }]
gifts
:
[],
// [{ date, occasion, description, image }]
gifts
:
[],
// [{ date, occasion, description, image }]
postcards
:
[],
// [{ date, location, description, image }]
postcards
:
[],
// [{ date, location, description, image }]
...
@@ -122,6 +124,10 @@ function reducer(state, action) {
...
@@ -122,6 +124,10 @@ function reducer(state, action) {
const
id
=
action
.
payload
?.
id
??
null
;
const
id
=
action
.
payload
?.
id
??
null
;
return
{
...
state
,
selectedFriendId
:
id
};
return
{
...
state
,
selectedFriendId
:
id
};
}
}
case
'SELECT_EVENT'
:
{
const
id
=
action
.
payload
?.
id
??
null
;
return
{
...
state
,
selectedEventId
:
id
};
}
case
'TOGGLE_REL'
:
{
case
'TOGGLE_REL'
:
{
const
a
=
action
.
payload
?.
aId
;
const
a
=
action
.
payload
?.
aId
;
const
b
=
action
.
payload
?.
bId
;
const
b
=
action
.
payload
?.
bId
;
...
@@ -142,10 +148,10 @@ function reducer(state, action) {
...
@@ -142,10 +148,10 @@ function reducer(state, action) {
return
{
...
state
,
friends
};
return
{
...
state
,
friends
};
}
}
case
'ADD_EVENT'
:
{
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)
// Normalize to YYYY-MM-DD (Paris local semantics handled at render/grouping time)
const
dateStr
=
typeof
date
===
'string'
?
date
.
slice
(
0
,
10
)
:
isoDate
(
date
);
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
();
const
eventId
=
uuid
();
// Create one logical event id applied to each participant for deduplication across views
// Create one logical event id applied to each participant for deduplication across views
...
@@ -154,6 +160,7 @@ function reducer(state, action) {
...
@@ -154,6 +160,7 @@ function reducer(state, action) {
const
ev
=
{
const
ev
=
{
id
:
eventId
,
id
:
eventId
,
date
:
dateStr
,
date
:
dateStr
,
title
:
String
(
title
),
notes
:
String
(
notes
),
notes
:
String
(
notes
),
location
:
location
?
String
(
location
)
:
undefined
,
location
:
location
?
String
(
location
)
:
undefined
,
participants
:
[...
participants
],
participants
:
[...
participants
],
...
@@ -164,6 +171,65 @@ function reducer(state, action) {
...
@@ -164,6 +171,65 @@ function reducer(state, action) {
}
}
return
f
;
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
};
return
{
...
state
,
friends
};
}
}
case
'UPDATE_FRIEND'
:
{
case
'UPDATE_FRIEND'
:
{
...
@@ -224,6 +290,8 @@ export function useDunbarStore() {
...
@@ -224,6 +290,8 @@ export function useDunbarStore() {
const
selectFriend
=
useCallback
((
id
)
=>
dispatch
({
type
:
'SELECT_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
toggleRelationship
=
useCallback
((
aId
,
bId
)
=>
dispatch
({
type
:
'TOGGLE_REL'
,
payload
:
{
aId
,
bId
}
}),
[]);
const
addEvent
=
useCallback
((
payload
)
=>
dispatch
({
type
:
'ADD_EVENT'
,
payload
}),
[]);
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
(()
=>
{
const
resetData
=
useCallback
(()
=>
{
if
(
typeof
window
!==
'undefined'
)
{
if
(
typeof
window
!==
'undefined'
)
{
const
ok
=
window
.
confirm
(
'This will clear all Dunbar data. Continue?'
);
const
ok
=
window
.
confirm
(
'This will clear all Dunbar data. Continue?'
);
...
@@ -377,6 +445,7 @@ export function useDunbarStore() {
...
@@ -377,6 +445,7 @@ export function useDunbarStore() {
state
,
state
,
friends
:
state
.
friends
,
friends
:
state
.
friends
,
selectedFriendId
:
state
.
selectedFriendId
,
selectedFriendId
:
state
.
selectedFriendId
,
selectedEventId
:
state
.
selectedEventId
,
actions
:
{
actions
:
{
addFriend
,
addFriend
,
removeFriend
,
removeFriend
,
...
@@ -387,6 +456,8 @@ export function useDunbarStore() {
...
@@ -387,6 +456,8 @@ export function useDunbarStore() {
selectFriend
,
selectFriend
,
toggleRelationship
,
toggleRelationship
,
addEvent
,
addEvent
,
selectEvent
,
updateEvent
,
resetData
,
resetData
,
loadFromPayload
,
loadFromPayload
,
},
},
...
...
next/components/layout.js
View file @
fbdcaf0f
...
@@ -12,6 +12,18 @@ export const twitterHandle = "@PaulLouisNech";
...
@@ -12,6 +12,18 @@ 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
})
{
// 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
>
...
@@ -86,6 +98,16 @@ export default function Layout({ children, home }) {
...
@@ -86,6 +98,16 @@ export default function Layout({ children, home }) {
>
>
▲
▲
<
/a
>
<
/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
>
<
/footer
>
<
/div
>
<
/div
>
);
);
...
...
next/lib/dunbar-search.js
View file @
fbdcaf0f
...
@@ -67,24 +67,33 @@ export function buildSearchData(friends = []) {
...
@@ -67,24 +67,33 @@ export function buildSearchData(friends = []) {
// Aggregate tags from events + friend notes + rich profile
// Aggregate tags from events + friend notes + rich profile
const
aggTags
=
new
Set
();
const
aggTags
=
new
Set
();
const
addTagsFrom
=
(
txt
)
=>
{
const
addTagsFromAny
=
(
val
)
=>
{
for
(
const
t
of
extractTagsFromText
(
txt
||
''
))
aggTags
.
add
(
t
);
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
Any
(
f
.
notes
);
addTagsFrom
(
f
.
likes
);
addTagsFrom
Any
(
f
.
likes
);
addTagsFrom
(
f
.
dislikes
);
addTagsFrom
Any
(
f
.
dislikes
);
addTagsFrom
(
f
.
foodLikes
);
addTagsFrom
Any
(
f
.
foodLikes
);
addTagsFrom
(
f
.
foodDislikes
);
addTagsFrom
Any
(
f
.
foodDislikes
);
addTagsFrom
(
f
.
futureIdeas
);
addTagsFrom
Any
(
f
.
futureIdeas
);
addTagsFrom
(
f
.
quotes
);
addTagsFrom
Any
(
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
// Compose an extended notes blob to improve recall
const
profileBlob
=
[
const
profileBlob
=
[
f
.
notes
,
f
.
notes
,
f
.
likes
,
Array
.
isArray
(
f
.
likes
)
?
f
.
likes
.
join
(
', '
)
:
f
.
likes
,
f
.
dislikes
,
Array
.
isArray
(
f
.
dislikes
)
?
f
.
dislikes
.
join
(
', '
)
:
f
.
dislikes
,
f
.
foodLikes
,
f
.
foodLikes
,
f
.
foodDislikes
,
f
.
foodDislikes
,
f
.
futureIdeas
,
f
.
futureIdeas
,
...
@@ -94,7 +103,7 @@ export function buildSearchData(friends = []) {
...
@@ -94,7 +103,7 @@ export function buildSearchData(friends = []) {
f
.
carModel
,
f
.
carModel
,
]
]
.
filter
(
Boolean
)
.
filter
(
Boolean
)
.
join
(
'
\
n'
);
.
join
(
'
\
\
n'
);
const
friendDoc
=
{
const
friendDoc
=
{
id
:
`friend:
${
f
.
id
}
`
,
id
:
`friend:
${
f
.
id
}
`
,
...
@@ -138,6 +147,7 @@ export function buildSearchData(friends = []) {
...
@@ -138,6 +147,7 @@ export function buildSearchData(friends = []) {
id
:
`event:
${
e
.
id
}
`
,
id
:
`event:
${
e
.
id
}
`
,
kind
:
'event'
,
kind
:
'event'
,
refId
:
e
.
id
,
refId
:
e
.
id
,
title
:
e
.
title
||
''
,
notes
:
e
.
notes
||
''
,
notes
:
e
.
notes
||
''
,
location
:
e
.
location
||
''
,
location
:
e
.
location
||
''
,
tags
,
tags
,
...
@@ -188,9 +198,9 @@ export function buildSearchIndexes(friends = []) {
...
@@ -188,9 +198,9 @@ export function buildSearchIndexes(friends = []) {
});
});
const
eventIndex
=
makeMiniSearch
(
const
eventIndex
=
makeMiniSearch
(
eventDocs
,
eventDocs
,
[
'notes'
,
'location'
,
'tags'
,
'participantNames'
],
[
'
title'
,
'
notes'
,
'location'
,
'tags'
,
'participantNames'
],
[
'id'
,
'kind'
,
'refId'
,
'tags'
,
'participantNames'
,
'date'
],
[
'id'
,
'kind'
,
'refId'
,
't
itle'
,
't
ags'
,
'participantNames'
,
'date'
],
{
tags
:
2
,
participantNames
:
1.5
}
{
t
itle
:
3
,
t
ags
:
2
,
participantNames
:
1.5
}
);
);
return
{
friendIndex
,
eventIndex
,
tagSet
,
personSet
,
friendDocs
,
eventDocs
};
return
{
friendIndex
,
eventIndex
,
tagSet
,
personSet
,
friendDocs
,
eventDocs
};
...
...
next/lib/dunbar.js
View file @
fbdcaf0f
...
@@ -73,6 +73,41 @@ export function extractTags(text = '') {
...
@@ -73,6 +73,41 @@ export function extractTags(text = '') {
return
Array
.
from
(
tags
);
return
Array
.
from
(
tags
);
}
}
// Slugify helper for URLs
export
function
slugify
(
text
=
''
)
{
const
s
=
String
(
text
||
''
)
.
normalize
(
'NFD'
)
.
replace
(
/
[\u
0300-
\u
036f
]
/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
// Quick-date helpers (ISO YYYY-MM-DD) — Paris local calendar
export
function
todayISO
()
{
export
function
todayISO
()
{
return
isoDate
(
new
Date
(),
TIMEZONE
);
return
isoDate
(
new
Date
(),
TIMEZONE
);
...
@@ -209,16 +244,17 @@ export function makeExportPayload(state) {
...
@@ -209,16 +244,17 @@ export function makeExportPayload(state) {
birthday
:
f
.
birthday
||
null
,
birthday
:
f
.
birthday
||
null
,
notes
:
f
.
notes
||
''
,
notes
:
f
.
notes
||
''
,
// rich profile
// rich profile
likes
:
f
.
likes
||
''
,
likes
:
Array
.
isArray
(
f
.
likes
)
?
f
.
likes
:
(
f
.
likes
?
String
(
f
.
likes
).
split
(
/
[
,
\\
n
]
/
).
map
(
s
=>
s
.
trim
()).
filter
(
Boolean
)
:
[])
,
dislikes
:
f
.
dislikes
||
''
,
dislikes
:
Array
.
isArray
(
f
.
dislikes
)
?
f
.
dislikes
:
(
f
.
dislikes
?
String
(
f
.
dislikes
).
split
(
/
[
,
\\
n
]
/
).
map
(
s
=>
s
.
trim
()).
filter
(
Boolean
)
:
[])
,
foodLikes
:
f
.
foodLikes
||
''
,
foodLikes
:
f
.
foodLikes
||
''
,
foodDislikes
:
f
.
foodDislikes
||
''
,
foodDislikes
:
f
.
foodDislikes
||
''
,
wifiPassword
:
f
.
wifiPassword
||
''
,
wifiPassword
:
f
.
wifiPassword
||
''
,
carModel
:
f
.
carModel
||
''
,
carModel
:
f
.
carModel
||
''
,
workplace
:
f
.
workplace
||
''
,
workplace
:
f
.
workplace
||
''
,
schedule
:
f
.
schedule
||
''
,
schedule
:
f
.
schedule
||
''
,
futureIdeas
:
f
.
futureIdeas
||
''
,
futureIdeas
:
Array
.
isArray
(
f
.
futureIdeas
)
?
f
.
futureIdeas
:
(
f
.
futureIdeas
?
String
(
f
.
futureIdeas
).
split
(
/
[
,
\n]
/
).
map
(
s
=>
s
.
trim
()).
filter
(
Boolean
)
:
[]),
quotes
:
f
.
quotes
||
''
,
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
=>
({
importantDates
:
Array
.
isArray
(
f
.
importantDates
)
?
f
.
importantDates
.
map
(
x
=>
({
date
:
x
?.
date
||
null
,
date
:
x
?.
date
||
null
,
label
:
x
?.
label
||
''
,
label
:
x
?.
label
||
''
,
...
@@ -239,6 +275,7 @@ export function makeExportPayload(state) {
...
@@ -239,6 +275,7 @@ export function makeExportPayload(state) {
events
:
Array
.
isArray
(
f
.
events
)
?
f
.
events
.
map
(
ev
=>
({
events
:
Array
.
isArray
(
f
.
events
)
?
f
.
events
.
map
(
ev
=>
({
id
:
ev
.
id
,
id
:
ev
.
id
,
date
:
ev
.
date
,
date
:
ev
.
date
,
title
:
ev
.
title
||
''
,
notes
:
ev
.
notes
,
notes
:
ev
.
notes
,
location
:
ev
.
location
,
location
:
ev
.
location
,
participants
:
Array
.
isArray
(
ev
.
participants
)
?
[...
ev
.
participants
]
:
[],
participants
:
Array
.
isArray
(
ev
.
participants
)
?
[...
ev
.
participants
]
:
[],
...
@@ -263,16 +300,17 @@ export function normalizeImportedPayload(payload) {
...
@@ -263,16 +300,17 @@ export function normalizeImportedPayload(payload) {
birthday
:
f
.
birthday
||
null
,
birthday
:
f
.
birthday
||
null
,
notes
:
f
.
notes
||
''
,
notes
:
f
.
notes
||
''
,
// rich profile (defaults)
// rich profile (defaults)
likes
:
f
.
likes
||
''
,
likes
:
Array
.
isArray
(
f
.
likes
)
?
f
.
likes
:
(
f
.
likes
?
String
(
f
.
likes
).
split
(
/
[
,
\\
n
]
/
).
map
(
s
=>
s
.
trim
()).
filter
(
Boolean
)
:
[])
,
dislikes
:
f
.
dislikes
||
''
,
dislikes
:
Array
.
isArray
(
f
.
dislikes
)
?
f
.
dislikes
:
(
f
.
dislikes
?
String
(
f
.
dislikes
).
split
(
/
[
,
\\
n
]
/
).
map
(
s
=>
s
.
trim
()).
filter
(
Boolean
)
:
[])
,
foodLikes
:
f
.
foodLikes
||
''
,
foodLikes
:
f
.
foodLikes
||
''
,
foodDislikes
:
f
.
foodDislikes
||
''
,
foodDislikes
:
f
.
foodDislikes
||
''
,
wifiPassword
:
f
.
wifiPassword
||
''
,
wifiPassword
:
f
.
wifiPassword
||
''
,
carModel
:
f
.
carModel
||
''
,
carModel
:
f
.
carModel
||
''
,
workplace
:
f
.
workplace
||
''
,
workplace
:
f
.
workplace
||
''
,
schedule
:
f
.
schedule
||
''
,
schedule
:
f
.
schedule
||
''
,
futureIdeas
:
f
.
futureIdeas
||
''
,
futureIdeas
:
Array
.
isArray
(
f
.
futureIdeas
)
?
f
.
futureIdeas
:
(
f
.
futureIdeas
?
String
(
f
.
futureIdeas
).
split
(
/
[
,
\n]
/
).
map
(
s
=>
s
.
trim
()).
filter
(
Boolean
)
:
[]),
quotes
:
f
.
quotes
||
''
,
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
=>
({
importantDates
:
Array
.
isArray
(
f
.
importantDates
)
?
f
.
importantDates
.
map
(
x
=>
({
date
:
x
?.
date
||
null
,
date
:
x
?.
date
||
null
,
label
:
x
?.
label
||
''
,
label
:
x
?.
label
||
''
,
...
@@ -293,6 +331,7 @@ export function normalizeImportedPayload(payload) {
...
@@ -293,6 +331,7 @@ export function normalizeImportedPayload(payload) {
events
:
Array
.
isArray
(
f
.
events
)
?
f
.
events
.
map
(
ev
=>
({
events
:
Array
.
isArray
(
f
.
events
)
?
f
.
events
.
map
(
ev
=>
({
id
:
ev
.
id
,
id
:
ev
.
id
,
date
:
ev
.
date
,
date
:
ev
.
date
,
title
:
ev
.
title
||
''
,
notes
:
ev
.
notes
||
''
,
notes
:
ev
.
notes
||
''
,
location
:
ev
.
location
,
location
:
ev
.
location
,
participants
:
Array
.
isArray
(
ev
.
participants
)
?
ev
.
participants
:
[],
participants
:
Array
.
isArray
(
ev
.
participants
)
?
ev
.
participants
:
[],
...
...
next/pages/dunbar/event/[slug].js
0 → 100644
View file @
fbdcaf0f
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
>
);
}
next/pages/dunbar/index.js
View file @
fbdcaf0f
...
@@ -9,6 +9,22 @@ export default function DunbarPage() {
...
@@ -9,6 +9,22 @@ export default function DunbarPage() {
<
Head
>
<
Head
>
<
title
>
Dunbar
—
Relationship
Navigator
<
/title
>
<
title
>
Dunbar
—
Relationship
Navigator
<
/title
>
<
meta
name
=
"robots"
content
=
"noindex"
/>
<
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
>
<
/Head
>
<
DunbarApp
/>
<
DunbarApp
/>
<
/Layout
>
<
/Layout
>
...
...
next/styles/dunbar.module.css
View file @
fbdcaf0f
...
@@ -250,6 +250,17 @@
...
@@ -250,6 +250,17 @@
color
:
#2c5530
;
color
:
#2c5530
;
font-size
:
0.8rem
;
font-size
:
0.8rem
;
line-height
:
1.4
;
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
{
.badge
{
...
@@ -399,6 +410,11 @@
...
@@ -399,6 +410,11 @@
justify-content
:
center
;
justify-content
:
center
;
font-weight
:
700
;
font-weight
:
700
;
color
:
#333
;
color
:
#333
;
transition
:
transform
80ms
ease
,
background
120ms
ease
;
}
.ctrlBtn
:active
{
transform
:
scale
(
0.96
);
background
:
#f6f6f6
;
}
}
.ctrlWide
{
.ctrlWide
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment