Commit 314d007a by PLN (Algolia)

dunbar: v1

parent a1863910
This source diff could not be displayed because it is too large. You can view the blob instead.
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';
// 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';
const PASSWORD = 'freehugs4all';
function Tabs({ tab, setTab }) {
const items = [
{ id: 'friends', label: 'Friends' },
{ 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 { 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
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = window.sessionStorage.getItem('dunbarAuthed');
if (stored === '1') {
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 = '';
}
};
// Navigation from viz → friend detail
const openFriendDetail = (friendId) => {
actions.selectFriend(friendId);
setTab('friends');
};
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={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)}
onRename={(id, name) => actions.renameFriend(id, name)}
// scroll preservation inside relationship list handled internally
/>
</div>
</div>
)}
{tab === 'events' && (
<EventsTab
friends={friends}
addEvent={(payload) => actions.addEvent(payload)}
eventIndex={derived.eventIndex}
/>
)}
{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} />
)}
</div>
);
}
import { useMemo, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import {
todayISO,
yesterdayISO,
weekAgoISO,
startOfMonthISO,
groupEventsByDay,
isoDate,
} from '@/lib/dunbar';
export default function EventsTab({ friends, addEvent, eventIndex }) {
// Creation form state
const [date, setDate] = useState(todayISO());
const [notes, setNotes] = useState('');
const [location, setLocation] = 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;
const createEvent = () => {
if (!canCreate) return;
const dateISO = new Date(date).toISOString();
addEvent({
date: dateISO,
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]);
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
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 }}>
<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 */}
<div className={styles.card}>
<div className={styles.cardHeader}>Timeline</div>
<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><strong>{names.join(', ') || 'Unknown'}</strong></div>
<div style={{ whiteSpace: 'pre-wrap' }}>{e.notes}</div>
{e.location ? (
<div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</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 } from '@/lib/dunbar';
export default function FriendDetail({
friend,
friends,
onToggleRel, // (aId, bId) => void
onAddEvent, // ({ date, notes, participants[], location? }) => void
}) {
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [notes, setNotes] = useState('');
const [location, setLocation] = useState('');
const relScrollRef = useRef(null);
// 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();
if (!dateISO || !n) return;
onAddEvent?.({
date: dateISO,
notes: n,
location: location.trim() || undefined,
participants: [friend.id],
});
// reset notes only; keep date for faster entry
setNotes('');
};
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>{friend.name}</span>
<span className={styles.badge}>{evCount} events · {connCount} connections</span>
</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>
{/* 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
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 }}>
<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={!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={{ whiteSpace: 'pre-wrap' }}>{e.notes}</div>
{e.location ? (
<div style={{ color: '#555', marginTop: 4 }}>📍 {e.location}</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';
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]);
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
</div>
<div className={styles.itemRight} aria-hidden></div>
<button
className={styles.btnSecondary}
style={{ marginLeft: 8 }}
onClick={(e) => {
e.stopPropagation();
const ok = window.confirm(`Remove ${f.name}? This doesn’t delete events from others.`);
if (!ok) return;
onRemoveFriend?.(f.id);
}}
>
Remove
</button>
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: 12, color: '#666' }}>
{friends.length === 0
? 'No friends yet — add your first contact above.'
: 'No matches for your filter.'}
</div>
)}
</div>
</div>
</div>
);
}
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from '@/styles/dunbar.module.css';
import Tooltip from '@/components/dunbar/Tooltip';
import {
distributeOnCircle,
colorByActivity,
firstWords,
isoDate,
} from '@/lib/dunbar';
function useSize(ref) {
const [size, setSize] = useState({ w: 800, h: 500 });
useEffect(() => {
if (!ref.current) return;
const el = ref.current;
const ro = new ResizeObserver(() => {
const r = el.getBoundingClientRect();
setSize({ w: Math.max(300, r.width), h: Math.max(300, r.height) });
});
ro.observe(el);
const r = el.getBoundingClientRect();
setSize({ w: Math.max(300, r.width), h: Math.max(300, r.height) });
return () => ro.disconnect();
}, [ref]);
return size;
}
function countRecentEvents(friend, days = 90) {
const now = Date.now();
const win = days * 24 * 60 * 60 * 1000;
let c = 0;
for (const e of friend.events || []) {
const t = new Date(e.date).getTime();
if (!isNaN(t) && now - t <= win) c += 1;
}
return c;
}
export default function OrbitsTab({ friends, buckets, openFriendDetail }) {
const wrapRef = useRef(null);
const { w, h } = useSize(wrapRef);
const cx = w / 2;
const cy = h / 2;
const rOuter = Math.min(w, h) * 0.45;
const rMiddle = Math.min(w, h) * 0.32;
const rInner = Math.min(w, h) * 0.18;
const friendMap = useMemo(() => {
const m = new Map();
for (const f of friends) m.set(f.id, f);
return m;
}, [friends]);
// Positions for each orbit
const posInner = useMemo(() => distributeOnCircle(buckets.inner || [], rInner, cx, cy, -Math.PI / 2), [buckets.inner, cx, cy, rInner]);
const posMiddle = useMemo(() => distributeOnCircle(buckets.middle || [], rMiddle, cx, cy, -Math.PI / 2), [buckets.middle, cx, cy, rMiddle]);
const posOuter = useMemo(() => distributeOnCircle(buckets.outer || [], rOuter, cx, cy, -Math.PI / 2), [buckets.outer, cx, cy, rOuter]);
const [tooltip, setTooltip] = useState({ x: 0, y: 0, show: false, html: null });
const handleEnter = (e, id) => {
const f = friendMap.get(id);
if (!f) return;
const totalEvents = (f.events || []).length;
const connectionCount = f.relationships ? f.relationships.size : 0;
const recent = [...(f.events || [])]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 3)
.map((ev) => `${isoDate(ev.date)}: ${firstWords(ev.notes, 3)}…`);
setTooltip({
x: e.clientX,
y: e.clientY,
show: true,
html: (
<div>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{f.name}</div>
<div>Total events: {totalEvents}</div>
<div>Connections: {connectionCount}</div>
{recent.length ? (
<div style={{ marginTop: 6 }}>
{recent.map((line, i) => (
<div key={i} style={{ color: '#555' }}>{line}</div>
))}
</div>
) : null}
</div>
),
});
};
const handleMove = (e) => {
setTooltip((t) => ({ ...t, x: e.clientX, y: e.clientY }));
};
const handleLeave = () => setTooltip((t) => ({ ...t, show: false }));
const renderNodes = (ids, posMap) => {
return ids.map((id) => {
const p = posMap.get(id);
const f = friendMap.get(id);
if (!p || !f) return null;
const c90 = countRecentEvents(f, 90);
const fill = colorByActivity(c90);
return (
<g key={id} transform={`translate(${p.x},${p.y})`} style={{ cursor: 'pointer' }}>
<circle
r={10}
fill={fill}
onMouseEnter={(e) => handleEnter(e, id)}
onMouseMove={handleMove}
onMouseLeave={handleLeave}
onClick={() => openFriendDetail?.(id)}
/>
<text className={styles.nodeLabel} textAnchor="middle" y={-14}>
{f.name}
</text>
</g>
);
});
};
return (
<div ref={wrapRef} className={styles.orbitsWrap}>
<svg width="100%" height="100%" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Orbits visualization">
{/* Orbits */}
<circle cx={cx} cy={cy} r={rOuter} fill="none" stroke="#e8efe9" />
<circle cx={cx} cy={cy} r={rMiddle} fill="none" stroke="#d7e7db" />
<circle cx={cx} cy={cy} r={rInner} fill="none" stroke="#c9e0cf" />
{/* Labels */}
<text x={cx} y={cy - rInner - 8} className={styles.orbitLabel} textAnchor="middle">Close</text>
<text x={cx} y={cy - rMiddle - 8} className={styles.orbitLabel} textAnchor="middle">Regular</text>
<text x={cx} y={cy - rOuter - 8} className={styles.orbitLabel} textAnchor="middle">Distant</text>
{/* Nodes */}
{renderNodes(buckets.inner || [], posInner)}
{renderNodes(buckets.middle || [], posMiddle)}
{renderNodes(buckets.outer || [], posOuter)}
</svg>
<Tooltip x={tooltip.x} y={tooltip.y} visible={tooltip.show}>
{tooltip.html}
</Tooltip>
</div>
);
}
import React from 'react';
import styles from '@/styles/dunbar.module.css';
export default function StatsTab({ stats }) {
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 },
];
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>
</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>
);
}
...@@ -7,7 +7,7 @@ import Router from 'next/router' ...@@ -7,7 +7,7 @@ import Router 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";
...@@ -78,7 +78,7 @@ export default function Layout({ children, home }) { ...@@ -78,7 +78,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"
......
// 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(' ');
}
// 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,
relationships: Array.from(f.relationships || []),
events: Array.isArray(f.events) ? f.events.map(ev => ({
id: ev.id,
date: ev.date,
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 || '',
relationships: new Set(Array.isArray(f.relationships) ? f.relationships : []),
events: Array.isArray(f.events) ? f.events.map(ev => ({
id: ev.id,
date: ev.date,
notes: ev.notes || '',
location: ev.location,
participants: Array.isArray(ev.participants) ? ev.participants : [],
})) : [],
lastInteraction: null, // computed by store
}));
return {
friends,
selectedFriendId: payload.selectedFriendId || null,
};
}
...@@ -19,6 +19,8 @@ ...@@ -19,6 +19,8 @@
"@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",
...@@ -41,5 +43,6 @@ ...@@ -41,5 +43,6 @@
"@types/react": "^18.2.61", "@types/react": "^18.2.61",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vercel": "^39" "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';
export default function DunbarPage() {
return (
<div className="container">
<Layout>
<Head>
<title>Dunbar Relationship Navigator</title>
<meta name="robots" content="noindex" />
</Head>
<DunbarApp />
</Layout>
</div>
);
}
/* Dunbar MVP styles (scoped via CSS Modules) */
.container {
padding: 16px;
max-width: 1200px;
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.4fr;
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;
}
.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;
}
.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;
}
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