mirror of
https://github.com/Heretek-AI/arcane-repo.git
synced 2026-07-01 18:25:50 -04:00
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:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
+13017
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
layout: browse
|
||||
title: Browse Templates
|
||||
---
|
||||
+6
-6
@@ -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.
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user