feat: Built /browse page with URL-synced filter state wiring FilterBar…

- docs/.vitepress/components/BrowseView.vue
- docs/.vitepress/components/TemplateGrid.vue
- docs/.vitepress/theme/index.ts
- docs/.vitepress/config.mts
- docs/index.md
- docs/browse.md

GSD-Task: S02/T02
This commit is contained in:
John Doe
2026-05-03 14:03:26 -04:00
parent 92838ee7a1
commit ec57c7c67d
9 changed files with 13227 additions and 10 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vitepress'
import { getAllTemplates } from '../data/templates'
import type { TemplateData } from '../data/templates'
import FilterBar from './FilterBar.vue'
import TemplateGrid from './TemplateGrid.vue'
defineOptions({ name: 'BrowseView' })
const router = useRouter()
const route = useRoute()
// ── Data ──────────────────────────────────────────────
const allTemplates = getAllTemplates()
// ── Filter state ──────────────────────────────────────
const searchQuery = ref('')
const selectedTags = ref<string[]>([])
const sortBy = ref('name-asc')
const currentPage = ref(1)
// ── Compute available tags sorted by frequency ────────
const tagCounts = new Map<string, number>()
for (const t of allTemplates) {
for (const tag of t.tags) {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)
}
}
const availableTags = [...tagCounts.entries()]
.sort((a, b) => b[1] - a[1])
.map(([tag]) => tag)
// ── Filtering + sorting ───────────────────────────────
const filteredTemplates = computed(() => {
let result = allTemplates
// Search filter (name, description, id)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase().trim()
result = result.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q)
)
}
// Tag filter (OR logic)
if (selectedTags.value.length > 0) {
result = result.filter((t) =>
selectedTags.value.some((tag) => t.tags.includes(tag))
)
}
// Sort
switch (sortBy.value) {
case 'name-desc':
result = [...result].sort((a, b) => b.name.localeCompare(a.name))
break
case 'category':
result = [...result].sort((a, b) => {
const catA = a.tags[0] || ''
const catB = b.tags[0] || ''
return catA.localeCompare(catB) || a.name.localeCompare(b.name)
})
break
default: // name-asc
result = [...result].sort((a, b) => a.name.localeCompare(b.name))
}
return result
})
// ── URL sync helpers ──────────────────────────────────
function readFiltersFromURL() {
const q = route.query
searchQuery.value = (q.q as string) || ''
selectedTags.value = q.tags
? (q.tags as string).split(',').filter(Boolean)
: []
sortBy.value = (q.sort as string) || 'name-asc'
currentPage.value = q.page ? parseInt(q.page as string, 10) || 1 : 1
}
function pushFiltersToURL(pageOverride?: number) {
const query: Record<string, string> = {}
if (searchQuery.value.trim()) query.q = searchQuery.value.trim()
if (selectedTags.value.length > 0) query.tags = selectedTags.value.join(',')
if (sortBy.value !== 'name-asc') query.sort = sortBy.value
const p = pageOverride ?? currentPage.value
if (p > 1) query.page = String(p)
const target = router.route.path
router.withoutScroll(() => {
router.replace(target, query)
})
}
// ── Initialize from URL on mount ──────────────────────
onMounted(() => {
readFiltersFromURL()
})
// ── Sync back/forward navigation ──────────────────────
watch(
() => route.query,
() => {
readFiltersFromURL()
}
)
// ── Push URL on filter changes ────────────────────────
watch([searchQuery, selectedTags, sortBy], () => {
currentPage.value = 1
pushFiltersToURL(1)
})
// ── Push URL on page change ───────────────────────────
function onPageChange(page: number) {
currentPage.value = page
pushFiltersToURL(page)
}
</script>
<template>
<div class="browse-view">
<h1 class="browse-view__title">Browse Templates</h1>
<p class="browse-view__subtitle">
Search, filter, and sort across {{ allTemplates.length }} self-hosted Docker templates.
</p>
<FilterBar
:searchQuery="searchQuery"
:selectedTags="selectedTags"
:sortBy="sortBy"
:availableTags="availableTags"
@update:searchQuery="searchQuery = $event"
@update:selectedTags="selectedTags = $event"
@update:sortBy="sortBy = $event"
/>
<TemplateGrid
:templates="filteredTemplates"
:initialPage="currentPage"
@update:currentPage="onPageChange"
/>
</div>
</template>
<style scoped>
.browse-view {
max-width: 1200px;
margin: 0 auto;
padding: 24px 24px 48px;
}
.browse-view__title {
font-size: 1.8em;
margin: 0 0 4px;
}
.browse-view__subtitle {
color: var(--vp-c-text-2);
margin: 0 0 24px;
font-size: 0.95em;
}
</style>
+15 -3
View File
@@ -6,17 +6,28 @@ const props = withDefaults(
defineProps<{
templates: TemplateData[]
pageSize?: number
initialPage?: number
}>(),
{ pageSize: 24 }
{ pageSize: 24, initialPage: 1 }
)
const currentPage = ref(1)
const emit = defineEmits<{
'update:currentPage': [page: number]
}>()
// Reset to page 1 when the filtered list changes
const currentPage = ref(props.initialPage)
// Reset to page 1 when the filtered list changes (but not on first mount)
const isFirstWatch = ref(true)
watch(
() => props.templates,
() => {
if (isFirstWatch.value) {
isFirstWatch.value = false
return
}
currentPage.value = 1
emit('update:currentPage', 1)
}
)
@@ -59,6 +70,7 @@ const visiblePages = computed(() => {
function goToPage(page: number) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
emit('update:currentPage', page)
}
}
</script>
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineConfig({
},
nav: [
{ text: 'Home', link: '/' },
{ text: 'Browse', link: '/categories/' },
{ text: 'Browse', link: '/browse' },
{ text: 'All Templates', link: '/templates/' },
{ text: 'GitHub', link: 'https://github.com/Heretek-AI/arcane-repo' }
],
+6
View File
@@ -4,6 +4,7 @@ import { useData } from 'vitepress'
import TemplateDetail from '../components/TemplateDetail.vue'
import FilterBar from '../components/FilterBar.vue'
import TemplateGrid from '../components/TemplateGrid.vue'
import BrowseView from '../components/BrowseView.vue'
import './custom.css'
export default {
@@ -12,6 +13,7 @@ export default {
app.component('TemplateDetail', TemplateDetail)
app.component('FilterBar', FilterBar)
app.component('TemplateGrid', TemplateGrid)
app.component('BrowseView', BrowseView)
},
Layout() {
const { frontmatter } = useData()
@@ -20,6 +22,10 @@ export default {
return h(TemplateDetail, { templateId: frontmatter.value.templateId })
}
if (frontmatter.value.layout === 'browse') {
return h(BrowseView)
}
return h(DefaultTheme.Layout)
},
}
+4
View File
@@ -0,0 +1,4 @@
---
layout: browse
title: Browse Templates
---
+6 -6
View File
@@ -6,18 +6,18 @@ hero:
tagline: Browse, search, and deploy 785+ containerized applications
actions:
- theme: brand
text: Browse Categories
link: /categories/
text: Browse All Templates
link: /browse
- theme: alt
text: All Templates
link: /templates/
text: Browse by Category
link: /categories/
features:
- title: 785+ Templates
details: Curated collection of self-hosted Docker Compose templates from Portainer, YunoHost, Umbrel, and more.
- title: Instant Search & Filters
details: Search by name or description, filter by tags, and sort results — all synced to shareable URLs.
- title: 40 Categories
details: Browse by category — AI, monitoring, CMS, security, storage, and dozens more.
- title: Instant Search
details: Find any template by name or description using built-in local search.
- title: Ready to Deploy
details: Every template includes a docker-compose.yml, .env.example, and documentation.
---