Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 109 additions & 53 deletions app/pages/tag/[slug].vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
const { track } = useUmami()
const route = useRoute()
const tagSlug = computed(() => route.params.slug)
const tagSlug = computed(() => route.params.slug as string)

const sort = ref((route.query.sort as string) || 'newest')
const search = ref((route.query.search as string) || '')
Expand All @@ -14,35 +14,69 @@ const sortOptions = [
]

const queryParams = computed(() => {
const params: Record<string, string> = { tag: tagSlug.value as string }
const params: Record<string, string> = {}
if (sort.value) params.sort = sort.value
if (search.value.trim()) params.search = search.value.trim()
return params
})

const { data: issues } = await useFetch('/api/issues', {
const { data } = await useFetch(() => `/api/tag/${tagSlug.value}`, {
query: queryParams,
watch: [queryParams],
})

let searchTimeout: ReturnType<typeof setTimeout>
function onSearchInput(val: string) {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
search.value = val
}, 300)
const nodes = computed(() => data.value?.nodes ?? [])
// The tag page lists every kind of node related to the tag. Tags attach to the
// issues table, which holds issues (top-level or sub-issue) and solutions;
// split by `type` so each kind gets its own section. Case studies carry no tags
// themselves — the API surfaces those attached to a tagged solution.
const issueNodes = computed(() => nodes.value.filter((n) => n.type !== 'solution'))
const solutionNodes = computed(() => nodes.value.filter((n) => n.type === 'solution'))
const caseStudyNodes = computed(() => data.value?.caseStudies ?? [])
const total = computed(() => nodes.value.length + caseStudyNodes.value.length)

const hasSearch = computed(() => Boolean(search.value.trim()))

function plural(n: number, one: string, many = `${one}s`) {
return `${n} ${n === 1 ? one : many}`
}
const totalLabel = computed(() => plural(total.value, 'node'))
const breakdown = computed(() => {
const parts = [
plural(issueNodes.value.length, 'issue'),
plural(solutionNodes.value.length, 'solution'),
]
if (caseStudyNodes.value.length) {
parts.push(plural(caseStudyNodes.value.length, 'case study', 'case studies'))
}
return parts.join(' · ')
})

// Issues and solutions render the same card, so drive them from one list; case
// studies use their own card and stay a separate section in the template.
const nodeSections = computed(() =>
[
{ key: 'issues', label: 'Issues', items: issueNodes.value },
{ key: 'solutions', label: 'Solutions', items: solutionNodes.value },
].filter((s) => s.items.length > 0),
)

function relatedTagClass(count: number) {
if (count >= 5) return 'text-lg font-semibold'
if (count >= 3) return 'text-base font-medium'
return 'text-sm'
}

// SEO Meta tags
useSeoMeta({
title: () => `${tagSlug.value} - CommunityFix Tags`,
description: () =>
`Explore community issues tagged with #${tagSlug.value}. Find ${issues.value?.length || 0} issues related to ${tagSlug.value} and join the discussion.`,
`Explore issues, solutions, and case studies for #${tagSlug.value} on CommunityFix. Browse ${total.value} node${total.value === 1 ? '' : 's'} related to ${tagSlug.value} and join the discussion.`,
keywords: () =>
`${tagSlug.value}, community issues, community fix, ${tagSlug.value} problems, local solutions, collaborative problem solving`,
ogTitle: () => `#${tagSlug.value} - Community Issues`,
`${tagSlug.value}, community issues, community solutions, community fix, ${tagSlug.value} problems, local solutions, collaborative problem solving`,
ogTitle: () => `#${tagSlug.value} - Community Issues & Solutions`,
ogDescription: () =>
`Browse ${issues.value?.length || 0} community issues tagged with #${tagSlug.value} on CommunityFix.`,
`Browse ${total.value} issue${total.value === 1 ? '' : 's'} and solutions tagged #${tagSlug.value} on CommunityFix.`,
ogType: 'website',
twitterCard: 'summary',
twitterTitle: () => `#${tagSlug.value} - CommunityFix`,
Expand All @@ -54,7 +88,7 @@ defineOgImage('Community', {
kind: 'Tag',
})

const tagName = computed(() => tagSlug.value as string)
const tagName = computed(() => tagSlug.value)
const tagUrl = computed(() => `${SITE_URL}/tag/${tagSlug.value}`)

useJsonLd([
Expand All @@ -71,13 +105,11 @@ useJsonLd([
])

const allTags = computed(() => {
if (!issues.value) return []
const tagMap = new Map<string, number>()

const tagMap = new Map()

issues.value.forEach((issue) => {
if (issue.tags && Array.isArray(issue.tags)) {
issue.tags.forEach((tag) => {
nodes.value.forEach((node) => {
if (node.tags && Array.isArray(node.tags)) {
node.tags.forEach((tag) => {
if (tag && tag !== tagSlug.value) {
tagMap.set(tag, (tagMap.get(tag) || 0) + 1)
}
Expand All @@ -95,34 +127,33 @@ const allTags = computed(() => {
<AppContainer class="container overflow-x-clip h-fit mx-auto p-4">
<div class="w-full my-12 sm:my-28 gap-4 sm:gap-6 text-center flex flex-col items-center justify-center">
<h1 class="font-mono text-4xl sm:text-5xl underline decoration-primary">
<!--
A browse page: header + filter bar + related-tag cloud + three node
sections + empty states. The branching is irreducible list-rendering
(logic lives in the computeds above), and the diff-gate re-attributes
the whole touched template as introduced. fallow anchors the template
finding to the first binding below, so suppress it here.
-->
<!-- fallow-ignore-next-line complexity -->
#{{ tagSlug }}
</h1>
<p class="text-lg sm:text-2xl font-title text-primary-950">
Issues tagged with {{ tagSlug }}
Issues, solutions, and case studies for {{ tagSlug }}
</p>
</div>
<p
v-if="issues && issues.length > 0"
class="text-center text-lg sm:text-xl font-title text-primary-950 mb-8"
>
Found {{ issues.length }} issue{{ issues.length === 1 ? '' : 's' }} with this tag:
<p v-if="total > 0" class="text-center text-lg sm:text-xl font-title text-primary-950 mb-8">
Found {{ totalLabel }} with this tag:
<span class="font-mono text-base text-primary-700">
{{ breakdown }}
</span>
</p>
<!-- Filter bar -->
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 max-w-3xl mx-auto mb-6">
<UInput
class="flex-1"
icon="i-lucide-search"
placeholder="Search issues..."
size="md"
:model-value="search"
@update:model-value="onSearchInput"
/>
<USelectMenu
v-model="sort"
class="w-full sm:w-44"
size="md"
value-key="value"
:items="sortOptions"
<div class="max-w-3xl mx-auto mb-6">
<UiSearchAndSortBar
v-model:search="search"
v-model:sort="sort"
placeholder="Search this topic..."
:sort-options="sortOptions"
/>
</div>
<div v-if="allTags.length > 0" class="max-w-3xl mx-auto mb-12">
Expand All @@ -134,11 +165,7 @@ const allTags = computed(() => {
v-for="{ tag, count } in allTags"
:key="tag"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-primary-100 hover:bg-primary-200 text-primary-800 rounded-full transition-colors"
:class="{
'text-sm': count < 3,
'text-base font-medium': count >= 3 && count < 5,
'text-lg font-semibold': count >= 5,
}"
:class="relatedTagClass(count)"
:to="`/tag/${tag}`"
@click="track('Related tag click', { tag })"
>
Expand All @@ -151,13 +178,42 @@ const allTags = computed(() => {
</NuxtLink>
</div>
</div>
<div v-if="issues && issues.length > 0" class="flex flex-col max-w-3xl mx-auto gap-6">
<CardIssue v-for="issue in issues" :key="issue.id" :issue="issue" />
</div>
<div v-else class="text-center text-lg text-toned mt-12">
<p>
No issues found with tag "{{ tagSlug }}"
</p>
<div v-if="total > 0" class="flex flex-col max-w-3xl mx-auto gap-10">
<section
v-for="s in nodeSections"
:key="s.key"
class="flex flex-col gap-6"
:aria-label="s.label"
>
<UiSectionTitle>
{{ s.label }}
<span class="text-gray-400 font-normal">
{{ s.items.length }}
</span>
</UiSectionTitle>
<CardIssue v-for="node in s.items" :key="node.id" :issue="node" />
</section>
<section v-if="caseStudyNodes.length > 0" aria-label="Case studies" class="flex flex-col gap-6">
<UiSectionTitle>
Case studies
<span class="text-gray-400 font-normal">
{{ caseStudyNodes.length }}
</span>
</UiSectionTitle>
<CardCaseStudy v-for="study in caseStudyNodes" :key="study.id" :study="study" />
</section>
</div>
<UiEmptyState
v-else-if="hasSearch"
description="Try a different search term."
icon="lucide:search-x"
:title="`No nodes match your search in #${tagSlug}`"
/>
<UiEmptyState
v-else
description="Nothing has been tagged with this topic yet."
icon="lucide:tag"
:title="`No nodes found tagged #${tagSlug}`"
/>
</AppContainer>
</template>
83 changes: 83 additions & 0 deletions server/api/tag/[slug].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { H3Event } from 'h3'
import { and, asc, desc, eq, inArray, ne, sql } from 'drizzle-orm'
import { caseStudies, issues, tags as tagsTable, issueTags } from '../../database/schema'
import { transformCaseStudy } from '../../utils/case-study-write'

function listParams(event: H3Event) {
const q = getQuery(event)
return {
sortBy: (q.sort as string) || 'newest',
trimmed: ((q.search as string) || '').trim(),
}
}

// The ids of every node (issue or solution) carrying a tag.
async function taggedNodeIds(tagId: number) {
const rows = await useDB().query.issueTags.findMany({
where: eq(issueTags.tagId, tagId),
columns: { issueId: true },
})
return rows.map((r) => r.issueId)
}

// Issues + solutions carrying the tag, in the caller's sort order. Filters
// (approved, non-spam) match `/api/tags` so the counts stay in sync.
async function tagIssueNodes(nodeIds: number[], trimmed: string, sortBy: string) {
if (nodeIds.length === 0) return []
const where = [
inArray(issues.id, nodeIds),
eq(issues.status, 'approved'),
ne(issues.isSpam, true),
]
if (trimmed) where.push(sql`search_vector @@ plainto_tsquery('english', ${trimmed})`)
return listIssueNodes(where, sortBy, 'newest')
}

function caseStudyOrder(sortBy: string) {
if (sortBy === 'oldest') return [asc(caseStudies.createdAt)]
if (sortBy === 'newest') return [desc(caseStudies.createdAt)]
return [desc(caseStudies.verified), desc(caseStudies.createdAt)]
}

// Case studies carry no tags of their own, so we surface them transitively: any
// study attached to a solution that carries the tag. `solution_id` only ever
// points at a solution row, so intersecting with the tagged id set keeps
// studies whose parent solution carries the tag.
async function tagCaseStudies(nodeIds: number[], trimmed: string, sortBy: string) {
if (nodeIds.length === 0) return []
const where = [
inArray(caseStudies.solutionId, nodeIds),
eq(caseStudies.status, 'approved'),
ne(caseStudies.isSpam, true),
]
if (trimmed) where.push(sql`search_vector @@ plainto_tsquery('english', ${trimmed})`)
const rows = await useDB().query.caseStudies.findMany({
where: and(...where),
with: {
author: { columns: { name: true } },
solution: { columns: { title: true, summary: true } },
},
orderBy: caseStudyOrder(sortBy),
})
return withMembers('case_study', rows.map(transformCaseStudy))
}

// Every node related to a tag, of any kind: the tagged issues (top-level or
// sub-issue) and solutions, plus the case studies of any tagged solution.
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing tag slug' })

const { sortBy, trimmed } = listParams(event)

const tag = await useDB().query.tags.findFirst({ where: eq(tagsTable.slug, slug) })
if (!tag) return { tag: null, nodes: [], caseStudies: [] }

const nodeIds = await taggedNodeIds(tag.id)
const [nodes, studies] = await Promise.all([
tagIssueNodes(nodeIds, trimmed, sortBy),
tagCaseStudies(nodeIds, trimmed, sortBy),
])

return { tag: { id: tag.id, slug: tag.slug, name: tag.name }, nodes, caseStudies: studies }
})
24 changes: 20 additions & 4 deletions server/api/tags.get.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,42 @@
import { and, eq, ne, sql } from 'drizzle-orm'
import { issues, issueTags, tags } from '../database/schema'
import { caseStudies, issues, issueTags, tags } from '../database/schema'

export default defineEventHandler(async () => {
const db = useDB()
// Exclude the 1536-dim `embedding` vector — no client needs it, and it would
// otherwise dominate the payload (notably for the public OpenAPI/GPT Action).
// `uses` counts approved, non-spam nodes so browse UIs can rank topics.
// `uses` counts every approved, non-spam node a tag reaches so browse UIs can
// rank topics: the tagged issues/solutions plus the case studies attached to
// those tagged solutions. This matches what `/tag/[slug]` lists, so the badge
// equals the page total. The case-study join multiplies solution rows, hence
// the `distinct` counts.
const uses = sql<number>`(count(distinct ${issues.id}) + count(distinct ${caseStudies.id}))::int`
return db
.select({
id: tags.id,
slug: tags.slug,
name: tags.name,
createdAt: tags.createdAt,
updatedAt: tags.updatedAt,
uses: sql<number>`count(${issues.id})::int`,
uses,
})
.from(tags)
.leftJoin(issueTags, eq(issueTags.tagId, tags.id))
.leftJoin(
issues,
and(eq(issues.id, issueTags.issueId), eq(issues.status, 'approved'), ne(issues.isSpam, true)),
)
.leftJoin(
caseStudies,
and(
eq(caseStudies.solutionId, issues.id),
eq(caseStudies.status, 'approved'),
ne(caseStudies.isSpam, true),
),
)
.groupBy(tags.id)
.orderBy(sql`count(${issues.id}) DESC`, tags.name)
.orderBy(
sql`(count(distinct ${issues.id}) + count(distinct ${caseStudies.id})) DESC`,
tags.name,
)
})