From 7c25193edb1445214b8188729e42eec7225269f5 Mon Sep 17 00:00:00 2001 From: Arnaud Gissinger Date: Mon, 13 Jul 2026 11:07:11 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20feat:=20catalog=20navigation?= =?UTF-8?q?=20rework,=20wanted=20skills,=20onboarding=20+=20newsletter=20p?= =?UTF-8?q?refs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation and discovery: - Rework home page: new hero, linked catalog stat line, catalog-wide full-text search (/api/search/quick) with grouped results, topic quick-nav chips, explore-card grid into every section - New browse pages: /issues (tag + SDG + sort filters), /solutions (status filter), /case-studies (outcome/scale/semantic filters), /tags (usage-ranked topic index), /map (fullscreen map of all geolocated content with layer toggles) - New endpoints: /api/stats, /api/map, /api/solutions, /api/search/quick; sdg filter on /api/issues; usage counts on /api/tags and /api/sdgs - Minimal redesign of case-study cards; solution cards drop irrelevant sub-issue/solution chips; sitemap + SEO for new pages - Mobile responsiveness pass across public pages Community features: - Wanted skills on issues/solutions: schema + unique index, CRUD endpoints, node-page section with social share, contribute card - Contribute page rework with always-on ways to help - Onboarding flow (/onboarding): skills, free-text interests with embedding-based topic preview (/api/tags/similar), newsletter opt-in with frequency + content preferences - user_interests (vector embeddings + HNSW index), newsletter_prefs, users.onboarded_at; settings sections for interests + newsletter Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- app/components/ExploreMap.vue | 192 + app/components/card/CaseStudy.vue | 92 +- app/components/card/Issue.vue | 39 +- app/components/issue/WantedSkills.vue | 174 + app/components/user/InterestsEditor.vue | 177 + app/components/user/NewsletterPrefs.vue | 132 + app/pages/case-studies.vue | 147 + app/pages/contribute.vue | 269 + app/pages/index.vue | 224 +- app/pages/issue/[issueId]/index.vue | 5 + app/pages/issues.vue | 166 + app/pages/map.vue | 124 + app/pages/onboarding.vue | 265 + app/pages/settings.vue | 36 +- app/pages/solutions.vue | 123 + app/pages/tag/[slug].vue | 2 +- app/pages/tags.vue | 90 + content/guide/ai-agents.md | 112 - content/guide/authoring.md | 213 + content/guide/evidence.md | 97 - content/guide/getting-started.md | 78 - content/guide/structuring.md | 118 - content/guide/writing.md | 212 - content/mcp.md | 47 +- designs/brand.pen | 9248 +++++++++++++++++ designs/grain.glsl | 58 + public/llms.txt | 4 +- server/api/_auth/post-login-redirect.get.ts | 25 +- server/api/admin/logs/[id].get.ts | 22 + server/api/contribute.get.ts | 8 + server/api/issue/[id]/wanted-skills.get.ts | 33 + server/api/issue/[id]/wanted-skills.post.ts | 75 + .../[id]/wanted-skills/[skillId].delete.ts | 50 + server/api/issues.get.ts | 17 +- server/api/map.get.ts | 59 + server/api/mcp/index.post.ts | 47 +- server/api/me/interests.get.ts | 17 + server/api/me/interests.post.ts | 71 + server/api/me/interests/[id].delete.ts | 22 + server/api/me/newsletter.get.ts | 28 + server/api/me/newsletter.put.ts | 69 + server/api/me/onboarded.post.ts | 17 + server/api/sdgs.get.ts | 24 +- server/api/search/quick.get.ts | 40 + server/api/solutions.get.ts | 47 + server/api/stats.get.ts | 29 + server/api/tags.get.ts | 22 +- server/api/tags/similar.get.ts | 28 + .../migrations/0019_boring_menace.sql | 2 + .../database/migrations/0020_smart_blur.sql | 11 + .../migrations/0021_vengeful_whizzer.sql | 20 + .../custom/0006_help_labels_indexes.sql | 6 + .../custom/0007_wanted_skills_unique.sql | 6 + .../custom/0008_user_interests_indexes.sql | 10 + .../migrations/meta/0019_snapshot.json | 2132 ++++ .../migrations/meta/0020_snapshot.json | 2219 ++++ .../migrations/meta/0021_snapshot.json | 2350 +++++ server/database/migrations/meta/_journal.json | 23 +- server/database/schema.ts | 113 +- .../.well-known/mcp/server-card.json.get.ts | 30 +- server/routes/auth/google.get.ts | 7 +- server/routes/sitemap.xml.get.ts | 7 +- server/utils/auth-store.ts | 2 +- server/utils/case-study-write.ts | 1 + server/utils/discovery.ts | 82 +- server/utils/mcp-schemas.ts | 8 + server/utils/transform-issue.ts | 1 + tests/api/onboarding-prefs.test.ts | 89 + tests/api/wanted-skills.test.ts | 64 + tests/unit/llms-txt.test.ts | 2 +- workers/moderation/src/help-labels.ts | 98 + workers/moderation/src/index.ts | 8 + workers/moderation/src/pipelines.ts | 62 + 74 files changed, 19792 insertions(+), 759 deletions(-) create mode 100644 app/components/ExploreMap.vue create mode 100644 app/components/issue/WantedSkills.vue create mode 100644 app/components/user/InterestsEditor.vue create mode 100644 app/components/user/NewsletterPrefs.vue create mode 100644 app/pages/case-studies.vue create mode 100644 app/pages/contribute.vue create mode 100644 app/pages/issues.vue create mode 100644 app/pages/map.vue create mode 100644 app/pages/onboarding.vue create mode 100644 app/pages/solutions.vue create mode 100644 app/pages/tags.vue delete mode 100644 content/guide/ai-agents.md create mode 100644 content/guide/authoring.md delete mode 100644 content/guide/evidence.md delete mode 100644 content/guide/getting-started.md delete mode 100644 content/guide/structuring.md delete mode 100644 content/guide/writing.md create mode 100644 designs/brand.pen create mode 100644 designs/grain.glsl create mode 100644 server/api/admin/logs/[id].get.ts create mode 100644 server/api/contribute.get.ts create mode 100644 server/api/issue/[id]/wanted-skills.get.ts create mode 100644 server/api/issue/[id]/wanted-skills.post.ts create mode 100644 server/api/issue/[id]/wanted-skills/[skillId].delete.ts create mode 100644 server/api/map.get.ts create mode 100644 server/api/me/interests.get.ts create mode 100644 server/api/me/interests.post.ts create mode 100644 server/api/me/interests/[id].delete.ts create mode 100644 server/api/me/newsletter.get.ts create mode 100644 server/api/me/newsletter.put.ts create mode 100644 server/api/me/onboarded.post.ts create mode 100644 server/api/search/quick.get.ts create mode 100644 server/api/solutions.get.ts create mode 100644 server/api/stats.get.ts create mode 100644 server/api/tags/similar.get.ts create mode 100644 server/database/migrations/0019_boring_menace.sql create mode 100644 server/database/migrations/0020_smart_blur.sql create mode 100644 server/database/migrations/0021_vengeful_whizzer.sql create mode 100644 server/database/migrations/custom/0006_help_labels_indexes.sql create mode 100644 server/database/migrations/custom/0007_wanted_skills_unique.sql create mode 100644 server/database/migrations/custom/0008_user_interests_indexes.sql create mode 100644 server/database/migrations/meta/0019_snapshot.json create mode 100644 server/database/migrations/meta/0020_snapshot.json create mode 100644 server/database/migrations/meta/0021_snapshot.json create mode 100644 tests/api/onboarding-prefs.test.ts create mode 100644 tests/api/wanted-skills.test.ts create mode 100644 workers/moderation/src/help-labels.ts diff --git a/CLAUDE.md b/CLAUDE.md index d82acc5..0086775 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,9 +61,9 @@ CommunityFix exposes an MCP (Model Context Protocol) server at `POST /api/mcp` s - Access tokens are opaque, valid 1h, sha256-hashed in `oauth_tokens`. Refresh tokens rotate on every use; presenting an already-rotated refresh token triggers **reuse detection** — the whole token family for that client+user is revoked. - **Rate limiting:** KV-backed fixed-window limiter (`server/utils/rate-limit.ts`, counters in the `auth` KV namespace with a per-window TTL so KV evicts them — no purge needed; **fails open** if KV is unavailable so a KV blip can't take down auth/MCP). Per-IP on `/oauth/register` (10/h), `/oauth/token` + `/oauth/revoke` (60/5min); per-user on `/api/mcp` (300/min global, 40/10min writes, 60/min embedding-search tools). A single JSON-RPC request is capped at 50 batched calls (`MAX_BATCH`) so a giant batch of unmetered read tools can't bypass the global window. - **Cleanup:** `purgeExpired()` reaps expired codes and dead token rows (kept until the refresh window closes so reuse detection works). Run by the `oauth:purge` scheduled task (`nitro.scheduledTasks`, 3:15am UTC). -- Tools exposed: `search_issues_solutions` (vector search), `get_issue`, `get_tree`, `create_issue` / `create_solution`, `update_issue` / `update_solution` (split by node kind — solutions require `parentId`; updates error if `id` resolves to the other kind), `create_case_study` / `update_case_study` / `get_case_study` / `list_case_studies`, `suggest_more`, `whoami`, `get_user`, `search_tags`, `get_whitepaper` (serves `content/whitepaper.md` from the `content` Nuxt Content collection — `getWhitepaper()` in `server/utils/mcp-guides.ts` `queryCollection`s it server-side and reads the `rawbody` field, enabled by a `schema` on the `content` collection in `content.config.ts`), and `get_guide` (authoring guides — no `slug` lists them, a `slug` returns the markdown; sourced from the `guides` Nuxt Content collection `content/guide/*.md` via the same `server/utils/mcp-guides.ts`, reading the collection's `rawbody` field). Issues and solutions have two text fields: `summary` (required, plaintext, ≤280 chars — a real standalone synopsis, **not** a truncated prefix of the description) and `description` (optional, markdown). +- Tools exposed: `search_issues_solutions` (vector search), `get_issue`, `get_tree`, `create_issue` / `create_solution`, `update_issue` / `update_solution` (split by node kind — solutions require `parentId`; updates error if `id` resolves to the other kind), `create_case_study` / `update_case_study` / `get_case_study` / `list_case_studies`, `propose_edit` / `list_revisions` / `review_revision` (wiki-style editing — non-owner edits land as pending revision proposals, `applied: true/false` in the response), `suggest_more`, `whoami`, `get_user`, `search_tags`, `get_whitepaper` (serves `content/whitepaper.md` from the `content` Nuxt Content collection — `getWhitepaper()` in `server/utils/mcp-guides.ts` `queryCollection`s it server-side and reads the `rawbody` field, enabled by a `schema` on the `content` collection in `content.config.ts`), and `get_guide` (the single **authoring guide** `content/guide/authoring.md`, slug `authoring` — instruction-style, covers modeling/writing/evidence/MCP etiquette for humans and AI agents; no `slug` lists guides, a `slug` returns the markdown; sourced from the `guides` Nuxt Content collection via the same `server/utils/mcp-guides.ts`, reading the collection's `rawbody` field). Issues and solutions have two text fields: `summary` (required, plaintext, ≤280 chars — a real standalone synopsis, **not** a truncated prefix of the description) and `description` (optional, markdown). - **Dedup is advisory, not enforced:** there is no server-side duplicate gate. Server instructions + tool descriptions tell clients to `search_issues_solutions` before creating and to update the closest match instead of adding a near-duplicate, but `create_*` will create whatever it is asked to. -- Tools advertise MCP `annotations` (readOnly/destructive/idempotent hints) + `title`, declare permissive `outputSchema`, and return `structuredContent` (object-shaped results only). Input is Zod-validated against the published schema (`server/utils/mcp-schemas.ts`) before any tool runs. Writes log a `[mcp.audit]` line with the acting `clientId`. +- Tools advertise MCP `annotations` (readOnly/destructive/idempotent hints) + `title`, declare permissive `outputSchema`, and return `structuredContent` (object-shaped results only). Input is Zod-validated against the published schema (`server/utils/mcp-schemas.ts`) before any tool runs. Write tools accept an optional `model` field (self-reported model id of the AI client); writes log a `[mcp.audit]` line with the acting `clientId` and `model`. - Tool business logic lives in `server/utils/mcp-tools.ts`; the JSON-RPC plumbing in `server/api/mcp/index.post.ts` - Tables: `oauth_clients`, `oauth_codes`, `oauth_tokens` (migration `0007_cool_peter_quill.sql`); `resource` audience columns on `oauth_codes`/`oauth_tokens` (migration `0016_resource_audience_binding.sql`) diff --git a/app/components/ExploreMap.vue b/app/components/ExploreMap.vue new file mode 100644 index 0000000..d65f1fc --- /dev/null +++ b/app/components/ExploreMap.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/app/components/card/CaseStudy.vue b/app/components/card/CaseStudy.vue index 0e9272d..227a7b5 100644 --- a/app/components/card/CaseStudy.vue +++ b/app/components/card/CaseStudy.vue @@ -116,10 +116,9 @@ const sourceCount = computed(() => props.study.sources?.length ?? 0) {{ m.result }} @@ -194,29 +177,22 @@ const sourceCount = computed(() => props.study.sources?.length ?? 0) - -
-
- - - - {{ costDisplay }} - - - - · - - - {{ study.fundingSource }} - - - · - - - - {{ sourceCount }} source{{ sourceCount === 1 ? '' : 's' }} - -
+ +
+

+ {{ + [ + costDisplay, + study.fundingSource, + sourceCount ? `${sourceCount} source${sourceCount === 1 ? '' : 's'}` : null, + ] + .filter(Boolean) + .join(' · ') + }} +

+ + - - {{ issue.solutionCount || 0 }} Solutions - - - - {{ issue.subIssueCount || 0 }} Sub-issues + Case studies
diff --git a/app/components/issue/WantedSkills.vue b/app/components/issue/WantedSkills.vue new file mode 100644 index 0000000..38b027f --- /dev/null +++ b/app/components/issue/WantedSkills.vue @@ -0,0 +1,174 @@ + + + diff --git a/app/components/user/InterestsEditor.vue b/app/components/user/InterestsEditor.vue new file mode 100644 index 0000000..ea398db --- /dev/null +++ b/app/components/user/InterestsEditor.vue @@ -0,0 +1,177 @@ + + + diff --git a/app/components/user/NewsletterPrefs.vue b/app/components/user/NewsletterPrefs.vue new file mode 100644 index 0000000..5fbd306 --- /dev/null +++ b/app/components/user/NewsletterPrefs.vue @@ -0,0 +1,132 @@ + + + diff --git a/app/pages/case-studies.vue b/app/pages/case-studies.vue new file mode 100644 index 0000000..8691539 --- /dev/null +++ b/app/pages/case-studies.vue @@ -0,0 +1,147 @@ + + + diff --git a/app/pages/contribute.vue b/app/pages/contribute.vue new file mode 100644 index 0000000..e696544 --- /dev/null +++ b/app/pages/contribute.vue @@ -0,0 +1,269 @@ + + + diff --git a/app/pages/index.vue b/app/pages/index.vue index c6b5b9c..fa0502b 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -20,10 +20,30 @@ const queryParams = computed(() => { return params }) -const { data: issues } = await useFetch('/api/issues', { - query: queryParams, - watch: [queryParams], -}) +// The list below only needs the sort; searching switches to the catalog-wide +// quick search instead of filtering this list. +const issuesParams = computed(() => ({ + ...(sort.value && sort.value !== 'most_voted' && { sort: sort.value }), +})) +const quickParams = computed(() => ({ q: search.value.trim() })) + +const [{ data: stats }, { data: issues }, { data: tags }, { data: results }] = await Promise.all([ + useFetch('/api/stats'), + useFetch('/api/issues', { query: issuesParams, watch: [issuesParams] }), + useFetch('/api/tags'), + useFetch('/api/search/quick', { query: quickParams, watch: [quickParams] }), +]) + +const searching = computed(() => Boolean(search.value.trim())) +const resultGroups = computed(() => [ + { label: 'Issues', items: results.value?.issues ?? [], kind: 'issue' as const }, + { label: 'Solutions', items: results.value?.solutions ?? [], kind: 'issue' as const }, + { label: 'Case studies', items: results.value?.caseStudies ?? [], kind: 'case-study' as const }, +]) +const resultCount = computed(() => resultGroups.value.reduce((a, g) => a + g.items.length, 0)) + +// /api/tags is ordered by usage — the head of the list makes a good quick-nav. +const topTags = computed(() => (tags.value ?? []).filter((t) => t.uses > 0).slice(0, 8)) watch( queryParams, @@ -40,10 +60,92 @@ watch( { deep: true }, ) +// The stat strip is also the way into every other section of the site. +const statItems = computed(() => [ + { label: 'issues', value: stats.value?.issues ?? 0, to: '/issues' }, + { label: 'solutions', value: stats.value?.solutions ?? 0, to: '/solutions' }, + { label: 'case studies', value: stats.value?.caseStudies ?? 0, to: '/case-studies' }, + { label: 'topics', value: stats.value?.topics ?? 0, to: '/tags' }, +]) + +const NuxtLink = resolveComponent('NuxtLink') + +interface ExploreLink { + label: string + description: string + icon: string + to: string + event: string + // Half-width card (vs third-width), used for the reading links so the + // grid stays balanced: 6 content cards in rows of 3, then 2 wide cards. + wide?: boolean +} + +const exploreLinks = computed(() => [ + { + label: 'Issues', + description: `The full directory of ${stats.value?.issues ?? 0} issues, filterable by topic or UN goal`, + icon: 'lucide:circle-alert', + to: '/issues', + event: 'Homepage explore issues', + }, + { + label: 'Solutions', + description: `${stats.value?.solutions ?? 0} proposed approaches, from first plans to finished projects`, + icon: 'lucide:lightbulb', + to: '/solutions', + event: 'Homepage explore solutions', + }, + { + label: 'Case studies', + description: `${stats.value?.caseStudies ?? 0} documented implementations, including the failures`, + icon: 'lucide:map-pin', + to: '/case-studies', + event: 'Homepage explore case studies', + }, + { + label: 'Map', + description: 'Everything in the catalog that names a place', + icon: 'lucide:globe', + to: '/map', + event: 'Homepage explore map', + }, + { + label: 'Topics', + description: `${stats.value?.topics ?? 0} topics to browse the catalog by`, + icon: 'lucide:tags', + to: '/tags', + event: 'Homepage explore topics', + }, + { + label: 'Contribute', + description: 'Nodes that need evidence, sources, or costs. Pick one and improve it', + icon: 'lucide:hand-heart', + to: '/contribute', + event: 'Homepage explore contribute', + }, + { + label: 'Guides', + description: 'How to write issues, solutions, and case studies worth replicating', + icon: 'lucide:book-open', + to: '/guides', + event: 'Homepage explore guides', + wide: true, + }, + { + label: 'Whitepaper', + description: 'Why CommunityFix exists, its principles, and how the catalog is meant to be used', + icon: 'lucide:file-text', + to: '/whitepaper', + event: 'Homepage explore whitepaper', + wide: true, + }, +]) + useSeoMeta({ title: 'Community Solutions Hub', description: - 'Find and co-create solutions to local and global issues. Join CommunityFix to share skills, back ideas, and collaborate on impact projects.', + 'Find and co-create solutions to local and global issues. Browse documented problems, proposed solutions, and real-world case studies on CommunityFix.', ogTitle: 'CommunityFix – Put Your Skills to Work', ogDescription: 'Discover community-driven solutions and collaborate on projects that matter. Join CommunityFix to contribute skills, knowledge, and support.', @@ -56,38 +158,124 @@ defineOgImage('Home') diff --git a/app/pages/issue/[issueId]/index.vue b/app/pages/issue/[issueId]/index.vue index 8c13f7a..5468f5f 100644 --- a/app/pages/issue/[issueId]/index.vue +++ b/app/pages/issue/[issueId]/index.vue @@ -345,6 +345,11 @@ async function submitAppeal() { +
+const route = useRoute() +const router = useRouter() +const { track } = useUmami() + +const search = ref((route.query.search as string) || '') +const sort = ref((route.query.sort as string) || 'most_voted') +const tag = ref((route.query.tag as string) || '') +const sdg = ref((route.query.sdg as string) || '') + +const sortOptions = [ + { label: 'Newest', value: 'newest' }, + { label: 'Oldest', value: 'oldest' }, + { label: 'Most Voted', value: 'most_voted' }, + { label: 'Trending', value: 'trending' }, +] + +const [{ data: tags }, { data: sdgs }] = await Promise.all([ + useFetch('/api/tags'), + useFetch('/api/sdgs'), +]) + +const tagOptions = computed(() => [ + { label: 'All topics', value: '' }, + ...(tags.value ?? []) + .filter((t) => t.uses > 0) + .map((t) => ({ label: `#${t.slug} (${t.uses})`, value: t.slug })), +]) + +const sdgOptions = computed(() => [ + { label: 'All UN goals', value: '' }, + ...(sdgs.value ?? []) + .filter((s) => s.uses > 0) + .map((s) => ({ label: `${s.id}. ${s.name}`, value: String(s.id) })), +]) + +const queryParams = computed(() => { + const params: Record = {} + if (sort.value && sort.value !== 'most_voted') params.sort = sort.value + if (search.value.trim()) params.search = search.value.trim() + if (tag.value) params.tag = tag.value + if (sdg.value) params.sdg = sdg.value + return params +}) + +const { data: issues } = await useFetch('/api/issues', { + query: queryParams, + watch: [queryParams], +}) + +watch( + queryParams, + (params) => { + router.replace({ query: params }) + track('Filter issues', { + tag: tag.value || undefined, + sdg: sdg.value || undefined, + sort: sort.value, + }) + }, + { deep: true }, +) + +const hasFilters = computed(() => Boolean(search.value.trim() || tag.value || sdg.value)) +function resetFilters() { + search.value = '' + tag.value = '' + sdg.value = '' +} + +useSeoMeta({ + title: 'Browse Issues', + description: + 'The full directory of community-documented issues. Filter by topic, UN Sustainable Development Goal, or search the catalog.', + ogTitle: 'Browse Issues – CommunityFix', + ogDescription: + 'Explore every issue the community is working on. Filter by topic, UN goal, or keyword.', +}) + +defineOgImage('Community', { title: 'Issues', kind: 'Directory' }) + +useJsonLd([ + breadcrumbSchema([ + { name: 'Home', url: SITE_URL }, + { name: 'Issues', url: `${SITE_URL}/issues` }, + ]), + { + '@type': 'CollectionPage', + name: 'Issues', + url: `${SITE_URL}/issues`, + isPartOf: { '@id': WEBSITE_ID }, + }, +]) + + + diff --git a/app/pages/map.vue b/app/pages/map.vue new file mode 100644 index 0000000..412310c --- /dev/null +++ b/app/pages/map.vue @@ -0,0 +1,124 @@ + + + diff --git a/app/pages/onboarding.vue b/app/pages/onboarding.vue new file mode 100644 index 0000000..4b33e91 --- /dev/null +++ b/app/pages/onboarding.vue @@ -0,0 +1,265 @@ + + + diff --git a/app/pages/settings.vue b/app/pages/settings.vue index a4a02dd..d5a18ea 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -483,11 +483,45 @@ definePageMeta({ Add a credential -
+
03 + + Interests + +
+

+ Topics you care about, in your own words. They can be anything, even topics the + catalog does not cover yet. We use them to surface issues and wins that matter to you. +

+ + + +
+
+
+ + 04 + + + Newsletter + +
+

+ Choose whether we may email you and what to include. Nothing is sent without + your opt-in. +

+ + + +
+
+
+ + 05 + Session diff --git a/app/pages/solutions.vue b/app/pages/solutions.vue new file mode 100644 index 0000000..aff4e2f --- /dev/null +++ b/app/pages/solutions.vue @@ -0,0 +1,123 @@ + + + diff --git a/app/pages/tag/[slug].vue b/app/pages/tag/[slug].vue index ec432b1..7189518 100644 --- a/app/pages/tag/[slug].vue +++ b/app/pages/tag/[slug].vue @@ -93,7 +93,7 @@ const allTags = computed(() => {