diff --git a/package.json b/package.json
index 09a22b0..ed6796d 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"bech32": "^2.0.0",
"bip39": "^3.1.0",
"buffer": "^6.0.3",
+ "d3-force": "^3.0.0",
"date-fns": "^3.6.0",
"dompurify": "^3.0.6",
"dotenv": "^17.2.3",
@@ -88,6 +89,7 @@
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/typography": "^0.5.19",
+ "@types/d3-force": "^3.0.10",
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^13.0.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 67c7509..8924770 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -101,6 +101,9 @@ importers:
buffer:
specifier: ^6.0.3
version: 6.0.3
+ d3-force:
+ specifier: ^3.0.0
+ version: 3.0.0
date-fns:
specifier: ^3.6.0
version: 3.6.0
@@ -198,6 +201,9 @@ importers:
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.1.13)
+ '@types/d3-force':
+ specifier: ^3.0.10
+ version: 3.0.10
'@types/dompurify':
specifier: ^3.0.5
version: 3.2.0
@@ -1529,6 +1535,9 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
+ '@types/d3-force@3.0.10':
+ resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
@@ -2245,6 +2254,22 @@ packages:
engines: {node: '>=4'}
hasBin: true
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-force@3.0.0:
+ resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+ engines: {node: '>=12'}
+
+ d3-quadtree@3.0.1:
+ resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
d@1.0.2:
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
engines: {node: '>=0.12'}
@@ -6372,6 +6397,8 @@ snapshots:
'@types/cookie@0.6.0': {}
+ '@types/d3-force@3.0.10': {}
+
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.3.1
@@ -7201,6 +7228,18 @@ snapshots:
cssesc@3.0.0: {}
+ d3-dispatch@3.0.1: {}
+
+ d3-force@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-quadtree: 3.0.1
+ d3-timer: 3.0.1
+
+ d3-quadtree@3.0.1: {}
+
+ d3-timer@3.0.1: {}
+
d@1.0.2:
dependencies:
es5-ext: 0.10.64
diff --git a/src/components/CulinaryMesh.svelte b/src/components/CulinaryMesh.svelte
new file mode 100644
index 0000000..e7142ee
--- /dev/null
+++ b/src/components/CulinaryMesh.svelte
@@ -0,0 +1,610 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/DesktopSideNav.svelte b/src/components/DesktopSideNav.svelte
index de2dfef..0664d43 100644
--- a/src/components/DesktopSideNav.svelte
+++ b/src/components/DesktopSideNav.svelte
@@ -76,8 +76,7 @@
href: '/groups',
label: 'Groups',
icon: UsersThreeIcon,
- match: (p) => p.startsWith('/groups'),
- badge: 'members'
+ match: (p) => p.startsWith('/groups')
},
{
href: '/messages',
diff --git a/src/components/MembershipBeltBadge.svelte b/src/components/MembershipBeltBadge.svelte
new file mode 100644
index 0000000..e7e999d
--- /dev/null
+++ b/src/components/MembershipBeltBadge.svelte
@@ -0,0 +1,111 @@
+
+
+{#if isActiveMember}
+
+
+
+{/if}
diff --git a/src/components/UserSidePanel.svelte b/src/components/UserSidePanel.svelte
index d3007b8..81ce58c 100644
--- a/src/components/UserSidePanel.svelte
+++ b/src/components/UserSidePanel.svelte
@@ -445,9 +445,6 @@
>
The Pantry
- Members
diff --git a/src/components/mesh/MeshCanvas.svelte b/src/components/mesh/MeshCanvas.svelte
new file mode 100644
index 0000000..6e0adcb
--- /dev/null
+++ b/src/components/mesh/MeshCanvas.svelte
@@ -0,0 +1,187 @@
+
+
+
diff --git a/src/components/mesh/MeshControlPanel.svelte b/src/components/mesh/MeshControlPanel.svelte
new file mode 100644
index 0000000..e6332eb
--- /dev/null
+++ b/src/components/mesh/MeshControlPanel.svelte
@@ -0,0 +1,401 @@
+
+
+
+
+
+
+
+ {#if showFilters}
+
+
+
+
+ {#if searchValue}
+
+ {/if}
+
+
+
+
+
Layers:
+
+
+
+
+
+
+
+
+ {#if cuisineTags.length > 0}
+
+
Cuisine:
+
+ {#each cuisineTags.slice(0, 12) as tag}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if hasActiveFilters}
+
+
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/src/components/mesh/MeshDetailDrawer.svelte b/src/components/mesh/MeshDetailDrawer.svelte
new file mode 100644
index 0000000..9ada699
--- /dev/null
+++ b/src/components/mesh/MeshDetailDrawer.svelte
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/mesh/MeshHeroOverlay.svelte b/src/components/mesh/MeshHeroOverlay.svelte
new file mode 100644
index 0000000..b343b87
--- /dev/null
+++ b/src/components/mesh/MeshHeroOverlay.svelte
@@ -0,0 +1,427 @@
+
+
+
+
+
+
+
+
+ {#each stableNodes as node, i (node.id)}
+ {@const size = nodeSize(node)}
+ {#if node.type === 'recipe'}
+
+

+
+ {:else if node.type === 'tag'}
+
+
+ {#if node.emoji}{node.emoji}{/if}
+
+
+ {/if}
+ {/each}
+
+
+
+
+
+
+
+
diff --git a/src/components/mesh/MeshLegend.svelte b/src/components/mesh/MeshLegend.svelte
new file mode 100644
index 0000000..e34728d
--- /dev/null
+++ b/src/components/mesh/MeshLegend.svelte
@@ -0,0 +1,121 @@
+
+
+
+
+
+ {isConstellation ? 'Brightest stars' : 'Most loved'}
+
+
+
+ {isConstellation ? 'Notable stars' : 'Notable'}
+
+
+
+ {isConstellation ? 'Stars' : 'Community'}
+
+
+
+
{isConstellation ? 'Constellations' : 'Shared culture'}
+
+
+
+
diff --git a/src/components/mesh/MeshNodes.svelte b/src/components/mesh/MeshNodes.svelte
new file mode 100644
index 0000000..a6776ee
--- /dev/null
+++ b/src/components/mesh/MeshNodes.svelte
@@ -0,0 +1,437 @@
+
+
+
+
+
diff --git a/src/components/mesh/MeshStarfield.svelte b/src/components/mesh/MeshStarfield.svelte
new file mode 100644
index 0000000..c92d0ba
--- /dev/null
+++ b/src/components/mesh/MeshStarfield.svelte
@@ -0,0 +1,84 @@
+
+
+
+
+
diff --git a/src/components/mesh/MeshTooltip.svelte b/src/components/mesh/MeshTooltip.svelte
new file mode 100644
index 0000000..40ea6d0
--- /dev/null
+++ b/src/components/mesh/MeshTooltip.svelte
@@ -0,0 +1,39 @@
+
+
+{#if tooltipNode && !nodeDragDidMove}
+
+ {#if tooltipNode.tier === 1}🔥{/if}
+ {tooltipNode.title}
+
+{/if}
+
+
diff --git a/src/components/mesh/MeshZoomControls.svelte b/src/components/mesh/MeshZoomControls.svelte
new file mode 100644
index 0000000..e372e07
--- /dev/null
+++ b/src/components/mesh/MeshZoomControls.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+
diff --git a/src/lib/mesh/meshData.ts b/src/lib/mesh/meshData.ts
new file mode 100644
index 0000000..2200e83
--- /dev/null
+++ b/src/lib/mesh/meshData.ts
@@ -0,0 +1,456 @@
+import { ndk } from '$lib/nostr';
+import { get } from 'svelte/store';
+import type { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
+import { validateMarkdownTemplate } from '$lib/parser';
+import {
+ RECIPE_TAGS,
+ RECIPE_TAG_PREFIX_NEW,
+ RECIPE_TAG_PREFIX_LEGACY,
+ CURATED_TAG_SECTIONS,
+ TAG_ALIASES,
+ recipeTags,
+ GATED_RECIPE_KIND
+} from '$lib/consts';
+import { getImageOrPlaceholder } from '$lib/placeholderImages';
+import { nip19 } from 'nostr-tools';
+import type {
+ RecipeNode,
+ TagNode,
+ ChefNode,
+ MeshNode,
+ MeshEdge,
+ EngagementMap,
+ MeshLayers,
+ MeshFilters
+} from './meshTypes';
+
+// ── Build a flat set of all curated tag names ──────────────────
+
+const CURATED_TAG_SET = new Set(
+ CURATED_TAG_SECTIONS.flatMap((s) => s.tags)
+);
+
+// ── Build emoji lookup from recipeTags ─────────────────────────
+
+const TAG_EMOJI_MAP = new Map();
+for (const t of recipeTags) {
+ if (t.emoji) TAG_EMOJI_MAP.set(t.title, t.emoji);
+}
+
+// ── Fetch recipes ──────────────────────────────────────────────
+
+export async function fetchMeshRecipes(): Promise {
+ const ndkInstance = get(ndk);
+ if (!ndkInstance) return [];
+
+ const filter: NDKFilter = {
+ limit: 500,
+ kinds: [30023 as number],
+ '#t': RECIPE_TAGS
+ };
+
+ const recipes: NDKEvent[] = [];
+ const subscription = ndkInstance.subscribe(filter, { closeOnEose: true });
+
+ return new Promise((resolve) => {
+ let resolved = false;
+
+ const finalize = () => {
+ if (resolved) return;
+ resolved = true;
+ subscription.stop();
+ resolve(recipes);
+ };
+
+ const timeout = setTimeout(finalize, 8000);
+
+ subscription.on('event', (event: NDKEvent) => {
+ if (typeof validateMarkdownTemplate(event.content) !== 'string' && event.author?.pubkey) {
+ recipes.push(event);
+ }
+ if (recipes.length >= 150) {
+ clearTimeout(timeout);
+ finalize();
+ }
+ });
+
+ subscription.on('eose', () => {
+ clearTimeout(timeout);
+ finalize();
+ });
+ });
+}
+
+// ── Fetch engagement (likes + zaps) in batches ─────────────────
+
+export async function fetchMeshEngagement(recipes: NDKEvent[]): Promise {
+ const ndkInstance = get(ndk);
+ if (!ndkInstance) return new Map();
+
+ const aTagToEventId = new Map();
+ const allATags: string[] = [];
+
+ for (const recipe of recipes) {
+ const dTag = recipe.tags.find((t) => t[0] === 'd')?.[1];
+ if (!dTag || !recipe.author?.pubkey) continue;
+
+ const aTag = `30023:${recipe.author.pubkey}:${dTag}`;
+ aTagToEventId.set(aTag, recipe.id);
+ allATags.push(aTag);
+ }
+
+ const counters = new Map();
+ for (const eventId of aTagToEventId.values()) {
+ counters.set(eventId, { likes: 0, zaps: 0 });
+ }
+
+ const chunks: string[][] = [];
+ for (let i = 0; i < allATags.length; i += 40) {
+ chunks.push(allATags.slice(i, i + 40));
+ }
+
+ await Promise.all(
+ chunks.map(
+ (chunk) =>
+ new Promise((resolve) => {
+ const subscription = ndkInstance.subscribe(
+ { kinds: [7, 9735], '#a': chunk } as NDKFilter,
+ { closeOnEose: true }
+ );
+
+ const timeout = setTimeout(() => {
+ subscription.stop();
+ resolve();
+ }, 4000);
+
+ subscription.on('event', (event: NDKEvent) => {
+ const aTags = event.tags.filter((t) => t[0] === 'a').map((t) => t[1]);
+ for (const aTag of aTags) {
+ const eventId = aTagToEventId.get(aTag);
+ if (!eventId) continue;
+ const c = counters.get(eventId);
+ if (!c) continue;
+
+ if (event.kind === 7) {
+ c.likes++;
+ } else if (event.kind === 9735) {
+ c.zaps++;
+ }
+ }
+ });
+
+ subscription.on('eose', () => {
+ clearTimeout(timeout);
+ subscription.stop();
+ resolve();
+ });
+ })
+ )
+ );
+
+ const result: EngagementMap = new Map();
+ for (const [eventId, { likes, zaps }] of counters) {
+ const score = zaps * 3 + likes;
+ result.set(eventId, { likes, zaps, score });
+ }
+
+ return result;
+}
+
+// ── Extract normalized curated tags from an event ──────────────
+
+export function extractRecipeTags(event: NDKEvent): string[] {
+ const prefixNew = RECIPE_TAG_PREFIX_NEW + '-';
+ const prefixLegacy = RECIPE_TAG_PREFIX_LEGACY + '-';
+
+ const raw = event.tags
+ .filter((t) => t[0] === 't')
+ .map((t) => t[1] || '')
+ .filter(Boolean);
+
+ const seen = new Set();
+ const result: string[] = [];
+
+ for (const tag of raw) {
+ let stripped = tag;
+
+ const lower = stripped.toLowerCase();
+ if (lower.startsWith(prefixNew.toLowerCase())) {
+ stripped = stripped.slice(prefixNew.length);
+ } else if (lower.startsWith(prefixLegacy.toLowerCase())) {
+ stripped = stripped.slice(prefixLegacy.length);
+ }
+
+ stripped = stripped
+ .split('-')
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
+ .join('-');
+
+ if (TAG_ALIASES[stripped]) {
+ stripped = TAG_ALIASES[stripped];
+ }
+
+ if (CURATED_TAG_SET.has(stripped) && !seen.has(stripped)) {
+ seen.add(stripped);
+ result.push(stripped);
+ }
+ }
+
+ return result;
+}
+
+// ── Check if a recipe is gated ─────────────────────────────────
+
+function isRecipeGated(event: NDKEvent): boolean {
+ return event.kind === GATED_RECIPE_KIND ||
+ event.tags.some((t) => t[0] === 't' && t[1] === 'zapcooking-premium');
+}
+
+// ── Apply filters to a recipe node ─────────────────────────────
+
+function matchesFilters(node: RecipeNode, filters?: MeshFilters): boolean {
+ if (!filters) return true;
+
+ if (filters.search) {
+ const search = filters.search.toLowerCase();
+ const titleMatch = node.title.toLowerCase().includes(search);
+ const tagMatch = node.tags.some((t) => t.toLowerCase().includes(search));
+ if (!titleMatch && !tagMatch) return false;
+ }
+
+ if (filters.cuisine.length > 0) {
+ if (!filters.cuisine.some((c) => node.tags.includes(c))) return false;
+ }
+
+ if (filters.ingredient.length > 0) {
+ if (!filters.ingredient.some((i) => node.tags.includes(i))) return false;
+ }
+
+ if (filters.dietary.length > 0) {
+ if (!filters.dietary.some((d) => node.tags.includes(d))) return false;
+ }
+
+ if (filters.lightningGated === true && !node.isGated) return false;
+ if (filters.lightningGated === false && node.isGated) return false;
+
+ if (filters.creator && node.pubkey !== filters.creator) return false;
+
+ return true;
+}
+
+// ── Build the mesh graph ───────────────────────────────────────
+
+export function buildMeshGraph(
+ recipes: NDKEvent[],
+ engagement?: EngagementMap,
+ layers?: MeshLayers,
+ filters?: MeshFilters
+): { nodes: MeshNode[]; edges: MeshEdge[] } {
+ const activeLayers: MeshLayers = layers || { recipes: true, tags: true, chefs: false };
+ const nodes: MeshNode[] = [];
+ const edges: MeshEdge[] = [];
+ const tagCounts = new Map();
+
+ // First pass: create recipe nodes and count tags
+ const recipeNodes: RecipeNode[] = [];
+
+ for (const event of recipes) {
+ const tags = extractRecipeTags(event);
+ if (tags.length === 0) continue;
+
+ const imageUrl = event.tags.find((t) => t[0] === 'image')?.[1];
+ const image = getImageOrPlaceholder(imageUrl, event.id);
+ const title =
+ event.tags.find((t) => t[0] === 'title')?.[1] ||
+ event.tags.find((t) => t[0] === 'd')?.[1] ||
+ 'Untitled';
+
+ const d = event.tags.find((t) => t[0] === 'd')?.[1] || '';
+ const pubkey = event.author?.pubkey || '';
+ let link = '#';
+ try {
+ const naddr = nip19.naddrEncode({ identifier: d, kind: 30023, pubkey });
+ link = `/recipe/${naddr}`;
+ } catch {
+ // skip encoding errors
+ }
+
+ const eng = engagement?.get(event.id);
+ const likes = eng?.likes ?? 0;
+ const zaps = eng?.zaps ?? 0;
+ const score = eng?.score ?? 0;
+
+ const node: RecipeNode = {
+ id: `recipe-${event.id}`,
+ type: 'recipe',
+ event,
+ image,
+ title,
+ link,
+ tags,
+ score,
+ tier: 3,
+ zaps,
+ likes,
+ pubkey,
+ isGated: isRecipeGated(event)
+ };
+
+ // Apply filters before adding
+ if (!matchesFilters(node, filters)) continue;
+
+ recipeNodes.push(node);
+
+ for (const tag of tags) {
+ tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
+ }
+ }
+
+ // ── Tiering ────────────────────────────────────────────────────
+ const sorted = [...recipeNodes].sort((a, b) => b.score - a.score);
+ for (let i = 0; i < sorted.length; i++) {
+ if (i < 8) {
+ sorted[i].tier = 1;
+ } else if (i < 33) {
+ sorted[i].tier = 2;
+ } else {
+ sorted[i].tier = 3;
+ }
+ }
+
+ // Second pass: create tag nodes
+ const tagNodeMap = new Map();
+
+ if (activeLayers.tags) {
+ for (const [sectionIndex, section] of CURATED_TAG_SECTIONS.entries()) {
+ for (const tagName of section.tags) {
+ const count = tagCounts.get(tagName) || 0;
+ if (count === 0) continue;
+ if (tagNodeMap.has(tagName)) continue;
+
+ const emoji = TAG_EMOJI_MAP.get(tagName) || '';
+ const tagNode: TagNode = {
+ id: `tag-${tagName}`,
+ type: 'tag',
+ name: tagName,
+ emoji,
+ sectionTitle: section.title,
+ sectionIndex,
+ count
+ };
+
+ tagNodeMap.set(tagName, tagNode);
+ }
+ }
+ }
+
+ // Assemble nodes
+ if (activeLayers.tags) {
+ nodes.push(...tagNodeMap.values());
+ }
+ if (activeLayers.recipes) {
+ nodes.push(...recipeNodes);
+ }
+
+ // ── Recipe-tag edges ───────────────────────────────────────────
+ if (activeLayers.recipes && activeLayers.tags) {
+ for (const recipe of recipeNodes) {
+ for (const tag of recipe.tags) {
+ const tagNode = tagNodeMap.get(tag);
+ if (tagNode) {
+ edges.push({
+ source: recipe.id,
+ target: tagNode.id,
+ edgeType: 'recipe-tag',
+ weight: 1
+ });
+ }
+ }
+ }
+ }
+
+ // ── Recipe-recipe edges (shared tags) ──────────────────────────
+ if (activeLayers.recipes) {
+ const tagToRecipeIndices = new Map();
+ for (let i = 0; i < recipeNodes.length; i++) {
+ for (const tag of recipeNodes[i].tags) {
+ let list = tagToRecipeIndices.get(tag);
+ if (!list) {
+ list = [];
+ tagToRecipeIndices.set(tag, list);
+ }
+ list.push(i);
+ }
+ }
+
+ const sharedCounts = new Map();
+ for (const indices of tagToRecipeIndices.values()) {
+ for (let a = 0; a < indices.length; a++) {
+ for (let b = a + 1; b < indices.length; b++) {
+ const key = `${Math.min(indices[a], indices[b])}-${Math.max(indices[a], indices[b])}`;
+ sharedCounts.set(key, (sharedCounts.get(key) || 0) + 1);
+ }
+ }
+ }
+
+ const candidatePairs: { key: string; idxA: number; idxB: number; shared: number; pairScore: number }[] = [];
+ for (const [key, shared] of sharedCounts) {
+ if (shared < 2) continue;
+ const [a, b] = key.split('-').map(Number);
+ const pairScore = shared * (recipeNodes[a].score + recipeNodes[b].score);
+ candidatePairs.push({ key, idxA: a, idxB: b, shared, pairScore });
+ }
+
+ candidatePairs.sort((a, b) => b.pairScore - a.pairScore);
+ const topPairs = candidatePairs.slice(0, 250);
+
+ for (const pair of topPairs) {
+ edges.push({
+ source: recipeNodes[pair.idxA].id,
+ target: recipeNodes[pair.idxB].id,
+ edgeType: 'recipe-recipe',
+ weight: pair.shared
+ });
+ }
+ }
+
+ // ── Chef layer ─────────────────────────────────────────────────
+ if (activeLayers.chefs && activeLayers.recipes) {
+ const chefMap = new Map();
+
+ for (const recipe of recipeNodes) {
+ if (!recipe.pubkey) continue;
+ const existing = chefMap.get(recipe.pubkey);
+ if (existing) {
+ existing.count++;
+ existing.recipes.push(recipe);
+ } else {
+ chefMap.set(recipe.pubkey, { count: 1, recipes: [recipe] });
+ }
+ }
+
+ for (const [pubkey, { count, recipes: chefRecipes }] of chefMap) {
+ const chefNode: ChefNode = {
+ id: `chef-${pubkey}`,
+ type: 'chef',
+ pubkey,
+ displayName: '',
+ image: null,
+ recipeCount: count
+ };
+
+ nodes.push(chefNode);
+
+ for (const recipe of chefRecipes) {
+ edges.push({
+ source: recipe.id,
+ target: chefNode.id,
+ edgeType: 'recipe-chef',
+ weight: 1
+ });
+ }
+ }
+ }
+
+ return { nodes, edges };
+}
diff --git a/src/lib/mesh/meshLayout.ts b/src/lib/mesh/meshLayout.ts
new file mode 100644
index 0000000..a58fa47
--- /dev/null
+++ b/src/lib/mesh/meshLayout.ts
@@ -0,0 +1,109 @@
+import { browser } from '$app/environment';
+import type { PrecomputedLayout, MeshNode } from './meshTypes';
+
+const LAYOUT_CACHE_KEY = 'zc_mesh_layout_v1';
+const LAYOUT_TTL = 30 * 60 * 1000; // 30 minutes
+
+/**
+ * Generate a cache fingerprint from node IDs and edge count.
+ * Used to invalidate cache when graph data changes.
+ */
+function computeFingerprint(nodeIds: string[], edgeCount: number): string {
+ const sorted = [...nodeIds].sort();
+ // Simple hash: join sorted IDs and edge count
+ let hash = 0;
+ const str = sorted.join(',') + ':' + edgeCount;
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32-bit integer
+ }
+ return String(hash);
+}
+
+/**
+ * Store a computed layout in localStorage.
+ */
+export function setCachedLayout(
+ nodes: MeshNode[],
+ edgeCount: number
+): void {
+ if (!browser) return;
+
+ const positions: Record = {};
+ for (const node of nodes) {
+ if (node.x != null && node.y != null) {
+ positions[node.id] = { x: node.x, y: node.y };
+ }
+ }
+
+ const nodeIds = nodes.map((n) => n.id);
+ const fingerprint = computeFingerprint(nodeIds, edgeCount);
+
+ const layout: PrecomputedLayout & { fingerprint: string } = {
+ positions,
+ version: 1,
+ timestamp: Date.now(),
+ nodeCount: nodes.length,
+ edgeCount,
+ fingerprint
+ };
+
+ try {
+ localStorage.setItem(LAYOUT_CACHE_KEY, JSON.stringify(layout));
+ } catch {
+ // localStorage full or unavailable
+ }
+}
+
+/**
+ * Retrieve a cached layout if it exists and matches the current data fingerprint.
+ * Returns null if cache is stale, expired, or doesn't match.
+ */
+export function getCachedLayout(
+ nodeIds: string[],
+ edgeCount: number
+): Record | null {
+ if (!browser) return null;
+
+ try {
+ const raw = localStorage.getItem(LAYOUT_CACHE_KEY);
+ if (!raw) return null;
+
+ const layout: PrecomputedLayout & { fingerprint: string } = JSON.parse(raw);
+
+ // Check TTL
+ if (Date.now() - layout.timestamp > LAYOUT_TTL) {
+ localStorage.removeItem(LAYOUT_CACHE_KEY);
+ return null;
+ }
+
+ // Check fingerprint match
+ const fingerprint = computeFingerprint(nodeIds, edgeCount);
+ if (layout.fingerprint !== fingerprint) return null;
+
+ return layout.positions;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Apply cached positions to nodes. Returns true if all positions were applied.
+ */
+export function applyCachedLayout(
+ nodes: MeshNode[],
+ positions: Record
+): boolean {
+ let allApplied = true;
+ for (const node of nodes) {
+ const pos = positions[node.id];
+ if (pos) {
+ node.x = pos.x;
+ node.y = pos.y;
+ } else {
+ allApplied = false;
+ }
+ }
+ return allApplied;
+}
diff --git a/src/lib/mesh/meshStore.ts b/src/lib/mesh/meshStore.ts
new file mode 100644
index 0000000..40b2cd7
--- /dev/null
+++ b/src/lib/mesh/meshStore.ts
@@ -0,0 +1,24 @@
+import { writable } from 'svelte/store';
+import type { MeshFilters, MeshLayers, MeshVisualTheme } from './meshTypes';
+
+export const meshFilters = writable({
+ search: '',
+ cuisine: [],
+ ingredient: [],
+ difficulty: [],
+ time: [],
+ dietary: [],
+ lightningGated: null,
+ membershipTier: null,
+ creator: null
+});
+
+export const meshLayers = writable({
+ recipes: true,
+ tags: true,
+ chefs: false
+});
+
+export const meshVisualTheme = writable('default');
+
+export const meshSelectedNodeId = writable(null);
diff --git a/src/lib/mesh/meshTheme.ts b/src/lib/mesh/meshTheme.ts
new file mode 100644
index 0000000..0b055b6
--- /dev/null
+++ b/src/lib/mesh/meshTheme.ts
@@ -0,0 +1,156 @@
+import type { MeshVisualTheme } from './meshTypes';
+
+export interface MeshColorScheme {
+ // Edge colors
+ edgeRecipeTag: { base: string; highlighted: string; dim: string };
+ edgeRecipeRecipe: { base: string; highlighted: string; dim: string; glow: string };
+ edgeRecipeChef: { base: string; highlighted: string; dim: string };
+
+ // Node colors
+ nodeHeroBorder: string;
+ nodeHeroGlow: string;
+ nodeNotableBorder: string;
+ nodeNotableGlow: string;
+ nodeCommunityBorder: string;
+ nodeTagBg: string;
+ nodeTagBorder: string;
+ nodeTagBgHover: string;
+ nodeChefGlow: string;
+
+ // Container
+ containerBgDark: string;
+ containerBgLight: string;
+
+ // Legend
+ legendBgDark: string;
+ legendBgLight: string;
+ legendBorderDark: string;
+ legendBorderLight: string;
+ legendTextDark: string;
+ legendTextLight: string;
+}
+
+export const DEFAULT_COLORS: MeshColorScheme = {
+ edgeRecipeTag: {
+ base: 'rgba(255, 122, 61, 0.08)',
+ highlighted: 'rgba(255, 122, 61, 0.25)',
+ dim: 'rgba(255, 122, 61, 0.02)'
+ },
+ edgeRecipeRecipe: {
+ base: 'rgba(251, 191, 36, 0.15)',
+ highlighted: 'rgba(251, 191, 36, 0.5)',
+ dim: 'rgba(251, 191, 36, 0.03)',
+ glow: 'rgba(251, 191, 36, 0.2)'
+ },
+ edgeRecipeChef: {
+ base: 'rgba(139, 92, 246, 0.12)',
+ highlighted: 'rgba(139, 92, 246, 0.35)',
+ dim: 'rgba(139, 92, 246, 0.02)'
+ },
+
+ nodeHeroBorder: 'rgb(249, 115, 22)',
+ nodeHeroGlow: 'rgba(249, 115, 22, 0.5)',
+ nodeNotableBorder: 'rgba(249, 115, 22, 0.5)',
+ nodeNotableGlow: 'rgba(249, 115, 22, 0.15)',
+ nodeCommunityBorder: 'var(--color-input-border)',
+ nodeTagBg: 'rgba(249, 115, 22, 0.08)',
+ nodeTagBorder: 'rgba(249, 115, 22, 0.2)',
+ nodeTagBgHover: 'rgba(249, 115, 22, 0.15)',
+ nodeChefGlow: 'rgba(139, 92, 246, 0.3)',
+
+ containerBgDark: 'radial-gradient(ellipse at center, rgba(20,15,12,1) 0%, rgba(10,8,6,1) 100%)',
+ containerBgLight: 'radial-gradient(ellipse at center, rgba(255,252,248,1) 0%, rgba(245,240,235,1) 100%)',
+
+ legendBgDark: 'rgba(20, 15, 12, 0.7)',
+ legendBgLight: 'rgba(255, 255, 255, 0.7)',
+ legendBorderDark: 'rgba(255, 255, 255, 0.08)',
+ legendBorderLight: 'rgba(0, 0, 0, 0.08)',
+ legendTextDark: 'rgba(255, 255, 255, 0.5)',
+ legendTextLight: 'rgba(0, 0, 0, 0.6)'
+};
+
+export const CONSTELLATION_COLORS: MeshColorScheme = {
+ edgeRecipeTag: {
+ base: 'rgba(180, 200, 240, 0.06)',
+ highlighted: 'rgba(180, 200, 240, 0.25)',
+ dim: 'rgba(180, 200, 240, 0.015)'
+ },
+ edgeRecipeRecipe: {
+ base: 'rgba(180, 200, 240, 0.1)',
+ highlighted: 'rgba(180, 200, 240, 0.4)',
+ dim: 'rgba(180, 200, 240, 0.02)',
+ glow: 'rgba(180, 200, 240, 0.15)'
+ },
+ edgeRecipeChef: {
+ base: 'rgba(180, 200, 240, 0.08)',
+ highlighted: 'rgba(180, 200, 240, 0.3)',
+ dim: 'rgba(180, 200, 240, 0.015)'
+ },
+
+ nodeHeroBorder: 'rgba(220, 230, 255, 0.9)',
+ nodeHeroGlow: 'rgba(200, 220, 255, 0.5)',
+ nodeNotableBorder: 'rgba(180, 200, 240, 0.5)',
+ nodeNotableGlow: 'rgba(180, 200, 240, 0.2)',
+ nodeCommunityBorder: 'rgba(140, 160, 200, 0.3)',
+ nodeTagBg: 'rgba(180, 200, 240, 0.08)',
+ nodeTagBorder: 'rgba(180, 200, 240, 0.25)',
+ nodeTagBgHover: 'rgba(180, 200, 240, 0.15)',
+ nodeChefGlow: 'rgba(180, 200, 240, 0.3)',
+
+ containerBgDark: 'radial-gradient(ellipse at center, rgba(10,12,25,1) 0%, rgba(3,5,15,1) 100%)',
+ containerBgLight: 'radial-gradient(ellipse at center, rgba(15,18,35,1) 0%, rgba(5,8,20,1) 100%)',
+
+ legendBgDark: 'rgba(10, 12, 25, 0.7)',
+ legendBgLight: 'rgba(10, 12, 25, 0.7)',
+ legendBorderDark: 'rgba(180, 200, 240, 0.1)',
+ legendBorderLight: 'rgba(180, 200, 240, 0.1)',
+ legendTextDark: 'rgba(180, 200, 240, 0.5)',
+ legendTextLight: 'rgba(180, 200, 240, 0.5)'
+};
+
+/**
+ * Get the color scheme for the given visual theme.
+ * In constellation mode, dark/light distinction doesn't apply — always uses dark space palette.
+ */
+export function getThemeColors(visualTheme: MeshVisualTheme): MeshColorScheme {
+ return visualTheme === 'constellation' ? CONSTELLATION_COLORS : DEFAULT_COLORS;
+}
+
+/**
+ * Get edge color for dark mode, factoring in visual theme.
+ */
+export function getEdgeColors(
+ visualTheme: MeshVisualTheme,
+ isDarkMode: boolean
+): {
+ recipeTagBase: string;
+ recipeTagHighlight: string;
+ recipeTagDim: string;
+ recipeRecipeGlow: string;
+} {
+ const colors = getThemeColors(visualTheme);
+ // Constellation always uses its own palette
+ if (visualTheme === 'constellation') {
+ return {
+ recipeTagBase: colors.edgeRecipeTag.base,
+ recipeTagHighlight: colors.edgeRecipeTag.highlighted,
+ recipeTagDim: colors.edgeRecipeTag.dim,
+ recipeRecipeGlow: colors.edgeRecipeRecipe.glow
+ };
+ }
+ // Default theme adapts to light/dark
+ if (isDarkMode) {
+ return {
+ recipeTagBase: 'rgba(255, 122, 61, 0.08)',
+ recipeTagHighlight: 'rgba(255, 122, 61, 0.25)',
+ recipeTagDim: 'rgba(255, 122, 61, 0.02)',
+ recipeRecipeGlow: 'rgba(251, 191, 36, 0.2)'
+ };
+ }
+ return {
+ recipeTagBase: 'rgba(236, 71, 0, 0.05)',
+ recipeTagHighlight: 'rgba(236, 71, 0, 0.18)',
+ recipeTagDim: 'rgba(236, 71, 0, 0.01)',
+ recipeRecipeGlow: 'rgba(234, 88, 12, 0.15)'
+ };
+}
diff --git a/src/lib/mesh/meshTypes.ts b/src/lib/mesh/meshTypes.ts
new file mode 100644
index 0000000..9c271f3
--- /dev/null
+++ b/src/lib/mesh/meshTypes.ts
@@ -0,0 +1,97 @@
+import type { NDKEvent } from '@nostr-dev-kit/ndk';
+import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
+import type { MembershipTier } from '$lib/stores/membershipStatus';
+
+// ── Node Types ──────────────────────────────────────────────────
+
+export type RecipeNode = {
+ id: string;
+ type: 'recipe';
+ event: NDKEvent;
+ image: string;
+ title: string;
+ link: string;
+ tags: string[];
+ score: number;
+ tier: 1 | 2 | 3;
+ zaps: number;
+ likes: number;
+ pubkey: string;
+ isGated?: boolean;
+ x?: number;
+ y?: number;
+};
+
+export type TagNode = {
+ id: string;
+ type: 'tag';
+ name: string;
+ emoji: string;
+ sectionTitle: string;
+ sectionIndex: number;
+ count: number;
+ x?: number;
+ y?: number;
+};
+
+export type ChefNode = {
+ id: string;
+ type: 'chef';
+ pubkey: string;
+ displayName: string;
+ image: string | null;
+ recipeCount: number;
+ membershipTier?: MembershipTier;
+ x?: number;
+ y?: number;
+};
+
+export type MeshNode = RecipeNode | TagNode | ChefNode;
+
+// ── Edge Types ──────────────────────────────────────────────────
+
+export type MeshEdge = {
+ source: string | MeshNode;
+ target: string | MeshNode;
+ edgeType: 'recipe-tag' | 'recipe-recipe' | 'recipe-chef';
+ weight: number;
+};
+
+// ── Engagement ──────────────────────────────────────────────────
+
+export type EngagementMap = Map;
+
+// ── State Types ─────────────────────────────────────────────────
+
+export type MeshLayers = {
+ recipes: boolean;
+ tags: boolean;
+ chefs: boolean;
+};
+
+export type MeshFilters = {
+ search: string;
+ cuisine: string[];
+ ingredient: string[];
+ difficulty: string[];
+ time: string[];
+ dietary: string[];
+ lightningGated: boolean | null;
+ membershipTier: MembershipTier | null;
+ creator: string | null;
+};
+
+export type MeshVisualTheme = 'default' | 'constellation';
+
+export type PrecomputedLayout = {
+ positions: Record;
+ version: number;
+ timestamp: number;
+ nodeCount: number;
+ edgeCount: number;
+};
+
+// ── Simulation augmented types ──────────────────────────────────
+
+export type SimMeshNode = MeshNode & SimulationNodeDatum;
+export type SimMeshEdge = MeshEdge & SimulationLinkDatum;
diff --git a/src/lib/mesh/meshViewport.ts b/src/lib/mesh/meshViewport.ts
new file mode 100644
index 0000000..9e8cd25
--- /dev/null
+++ b/src/lib/mesh/meshViewport.ts
@@ -0,0 +1,67 @@
+import type { MeshNode, MeshEdge, SimMeshNode } from './meshTypes';
+
+export interface ViewportBounds {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+const MARGIN = 100; // px margin outside viewport for pre-rendering
+
+/**
+ * Compute the viewport bounds in graph coordinates given pan/zoom/container size.
+ */
+export function getViewportBounds(
+ panX: number,
+ panY: number,
+ zoom: number,
+ containerWidth: number,
+ containerHeight: number
+): ViewportBounds {
+ return {
+ left: (-panX / zoom) - (MARGIN / zoom),
+ top: (-panY / zoom) - (MARGIN / zoom),
+ right: ((containerWidth - panX) / zoom) + (MARGIN / zoom),
+ bottom: ((containerHeight - panY) / zoom) + (MARGIN / zoom)
+ };
+}
+
+/**
+ * Check if a node is within the given viewport bounds.
+ */
+export function isInViewport(node: MeshNode, bounds: ViewportBounds): boolean {
+ const x = node.x ?? 0;
+ const y = node.y ?? 0;
+ return x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom;
+}
+
+/**
+ * Filter nodes to only those within the viewport bounds.
+ */
+export function getVisibleNodes(nodes: T[], bounds: ViewportBounds): T[] {
+ return nodes.filter((node) => isInViewport(node, bounds));
+}
+
+/**
+ * Check if an edge has at least one endpoint within viewport bounds.
+ * Used for canvas edge culling.
+ */
+export function isEdgeVisible(
+ edge: MeshEdge,
+ bounds: ViewportBounds
+): boolean {
+ const source = edge.source as SimMeshNode;
+ const target = edge.target as SimMeshNode;
+
+ if (source.x == null || source.y == null || target.x == null || target.y == null) {
+ return false;
+ }
+
+ const srcIn = source.x >= bounds.left && source.x <= bounds.right &&
+ source.y >= bounds.top && source.y <= bounds.bottom;
+ const tgtIn = target.x >= bounds.left && target.x <= bounds.right &&
+ target.y >= bounds.top && target.y <= bounds.bottom;
+
+ return srcIn || tgtIn;
+}
diff --git a/src/lib/meshUtils.ts b/src/lib/meshUtils.ts
new file mode 100644
index 0000000..fab4fc0
--- /dev/null
+++ b/src/lib/meshUtils.ts
@@ -0,0 +1,5 @@
+// Re-export shim — all mesh logic now lives in src/lib/mesh/*
+// This file exists for backward compatibility with existing imports.
+
+export { fetchMeshRecipes, fetchMeshEngagement, extractRecipeTags, buildMeshGraph } from './mesh/meshData';
+export type { RecipeNode, TagNode, ChefNode, MeshNode, MeshEdge, EngagementMap } from './mesh/meshTypes';
diff --git a/src/routes/explore/+page.svelte b/src/routes/explore/+page.svelte
index 4ee2068..29a39ac 100644
--- a/src/routes/explore/+page.svelte
+++ b/src/routes/explore/+page.svelte
@@ -17,10 +17,24 @@
import TrendingRecipeCard from '../../components/TrendingRecipeCard.svelte';
import PullToRefresh from '../../components/PullToRefresh.svelte';
import LongformFoodFeed from '../../components/LongformFoodFeed.svelte';
+ import MeshHeroOverlay from '../../components/mesh/MeshHeroOverlay.svelte';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { init, markOnce } from '$lib/perf/explorePerf';
import { userPublickey } from '$lib/nostr';
+ import { fetchMeshRecipes, fetchMeshEngagement, buildMeshGraph, type EngagementMap } from '$lib/meshUtils';
+ import type { MeshNode, MeshEdge } from '$lib/mesh/meshTypes';
+ import { getCachedLayout, applyCachedLayout } from '$lib/mesh/meshLayout';
+ import {
+ forceSimulation,
+ forceLink,
+ forceManyBody,
+ forceCenter,
+ forceCollide,
+ forceX,
+ forceY
+ } from 'd3-force';
+ import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
import { cookingToolsOpen, cookingToolsStore } from '$lib/stores/cookingToolsWidget';
import { browser } from '$app/environment';
import type { PageData } from './$types';
@@ -114,6 +128,11 @@
let loadingDiscover = true;
let cultureExpanded = false;
+ // Mesh hero state
+ let heroNodes: MeshNode[] = [];
+ let heroEdges: MeshEdge[] = [];
+ let heroReady = false;
+
// Compute section references reactively
$: intentSection = CURATED_TAG_SECTIONS.find((s) => s.title === 'Why are you cooking?');
$: cultureSection = CURATED_TAG_SECTIONS.find((s) => s.title === 'Explore by culture');
@@ -166,6 +185,67 @@
});
loadingPopular = false;
});
+
+ // Load mesh hero data (non-blocking, in parallel)
+ loadMeshHero();
+ }
+
+ async function loadMeshHero() {
+ try {
+ const meshRecipes = await fetchMeshRecipes();
+ if (meshRecipes.length === 0) return;
+
+ const meshEngagement = await fetchMeshEngagement(meshRecipes);
+
+ // Use a reduced set for the hero (top 40 recipes)
+ const topRecipes = meshRecipes.slice(0, 40);
+ const graph = buildMeshGraph(topRecipes, meshEngagement);
+
+ // Check for cached layout
+ const nodeIds = graph.nodes.map((n) => n.id);
+ const cached = getCachedLayout(nodeIds, graph.edges.length);
+ if (cached) {
+ applyCachedLayout(graph.nodes, cached);
+ heroNodes = graph.nodes;
+ heroEdges = graph.edges;
+ heroReady = true;
+ return;
+ }
+
+ // Quick simulation for hero positions — non-blocking
+ const simNodes = graph.nodes as (MeshNode & SimulationNodeDatum)[];
+ const sim = forceSimulation(simNodes)
+ .force('link', forceLink(graph.edges as any[]).id((d: any) => d.id).distance(70).strength(0.3))
+ .force('charge', forceManyBody().strength(-30))
+ .force('center', forceCenter(300, 250))
+ .force('collide', forceCollide().radius(25))
+ .stop();
+
+ // Settle in batches to avoid blocking the main thread
+ const BATCH = 50;
+ await new Promise((resolve) => {
+ function tickBatch() {
+ let ticked = 0;
+ while (sim.alpha() > 0.001 && ticked < BATCH) {
+ sim.tick();
+ ticked++;
+ }
+ if (sim.alpha() > 0.001) {
+ setTimeout(tickBatch, 0);
+ } else {
+ resolve();
+ }
+ }
+ tickBatch();
+ });
+
+ heroNodes = simNodes;
+ heroEdges = graph.edges;
+ heroReady = true;
+ } catch (err) {
+ // Mesh hero is non-critical — if it fails, just don't show it
+ console.warn('[MeshHero] Failed to load:', err);
+ }
}
async function handleRefresh() {
@@ -285,8 +365,19 @@
+
+ {#if heroReady || heroNodes.length > 0}
+
+ {/if}
+
-
+
🍳
Fresh from the Kitchen
diff --git a/src/routes/mesh/+page.svelte b/src/routes/mesh/+page.svelte
new file mode 100644
index 0000000..70195d1
--- /dev/null
+++ b/src/routes/mesh/+page.svelte
@@ -0,0 +1,188 @@
+
+
+
+ Culinary Mesh - zap.cooking
+
+
+
+
+
+
+
+
+ {#if error}
+
+ {:else}
+ {#if isConstellation}
+
+ {/if}
+
+ {#if dataReady}
+
+ {/if}
+
+ {#if loading}
+
+
+
+
+ {#if loadingPhase === 'recipes'}
+ Discovering recipes...
+ {:else if loadingPhase === 'engagement'}
+ Mapping community engagement...
+ {:else if loadingPhase === 'settling'}
+ Arranging the mesh...
+ {:else}
+ Loading the culinary mesh...
+ {/if}
+
+
+
+ {/if}
+
+ {#if !loading && !error}
+
meshComponent?.zoomIn()}
+ onZoomOut={() => meshComponent?.zoomOut()}
+ onResetZoom={() => meshComponent?.resetZoom()}
+ />
+ {/if}
+
+ {#if selectedNode}
+
+ {/if}
+ {/if}
+
+
+
+
diff --git a/src/routes/mesh/+page.ts b/src/routes/mesh/+page.ts
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/src/routes/mesh/+page.ts
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/src/routes/user/[slug]/+page.svelte b/src/routes/user/[slug]/+page.svelte
index f1c212b..14c1bd0 100644
--- a/src/routes/user/[slug]/+page.svelte
+++ b/src/routes/user/[slug]/+page.svelte
@@ -37,6 +37,7 @@
import { profileCacheManager } from '$lib/profileCache';
import { RECIPE_TAGS } from '$lib/consts';
import ArticleFeed from '../../../components/ArticleFeed.svelte';
+ import MembershipBeltBadge from '../../../components/MembershipBeltBadge.svelte';
export const data: PageData = {} as PageData;
@@ -1322,7 +1323,10 @@
{#if user?.npub}