Guides: procedures written and read inside ShopDB
This commit is contained in:
163
plugins/guides/frontend/views/GuidesList.vue
Normal file
163
plugins/guides/frontend/views/GuidesList.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="gl">
|
||||
<div class="page-header">
|
||||
<h2>Guides</h2>
|
||||
<router-link v-if="cancreate" class="gl-new" :to="{ name: 'guide-new' }">
|
||||
New guide
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="gl-filters">
|
||||
<input v-model="search" type="search" class="gl-search"
|
||||
placeholder="Search titles, summaries, tags and content..."
|
||||
@input="debouncedLoad">
|
||||
<div v-if="tags.length" class="gl-tags">
|
||||
<button type="button" class="gl-tag" :class="{ 'gl-tagon': !activeTag }"
|
||||
@click="setTag('')">All</button>
|
||||
<button v-for="tag in tags" :key="tag.tag" type="button"
|
||||
class="gl-tag" :class="{ 'gl-tagon': activeTag === tag.tag }"
|
||||
@click="setTag(tag.tag)">
|
||||
{{ tag.tag }} <span class="gl-count">{{ tag.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="gl-loading">Loading...</div>
|
||||
<div v-else-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-else-if="!guides.length" class="gl-empty">
|
||||
<p v-if="search || activeTag">Nothing matches that.</p>
|
||||
<p v-else>No guides yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="gl-grid">
|
||||
<router-link v-for="guide in guides" :key="guide.guideid" class="gl-card"
|
||||
:to="{ name: 'guide-view', params: { slug: guide.slug } }">
|
||||
<div class="gl-cardhead">
|
||||
<h3>{{ guide.title }}</h3>
|
||||
<!-- Drafts are visible here only to people who can edit them, so the
|
||||
badge is a working state rather than a warning. -->
|
||||
<span v-if="!guide.ispublished" class="gl-draft">Draft</span>
|
||||
</div>
|
||||
<p v-if="guide.summary" class="gl-summary">{{ guide.summary }}</p>
|
||||
<div class="gl-cardfoot">
|
||||
<span v-for="tag in guide.tags" :key="tag" class="gl-minitag">{{ tag }}</span>
|
||||
<span v-if="guide.checklistitems" class="gl-checks">
|
||||
{{ guide.checklistitems }} steps to tick
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const guides = ref([])
|
||||
const tags = ref([])
|
||||
const search = ref('')
|
||||
const activeTag = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
// computed, not a one-off read: permissions may not be loaded yet at setup.
|
||||
const cancreate = computed(() => auth.hasPermission?.('guides.create') ?? false)
|
||||
|
||||
let timer = null
|
||||
function debouncedLoad () {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(load, 250)
|
||||
}
|
||||
|
||||
function setTag (tag) {
|
||||
activeTag.value = tag
|
||||
router.replace({ query: tag ? { tag } : {} })
|
||||
load()
|
||||
}
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = {}
|
||||
if (search.value.trim()) params.q = search.value.trim()
|
||||
if (activeTag.value) params.tag = activeTag.value
|
||||
const response = await api.get('/guides', { params })
|
||||
guides.value = response.data?.data || response.data || []
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load guides')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTags () {
|
||||
try {
|
||||
const response = await api.get('/guides/tags')
|
||||
tags.value = response.data?.data || response.data || []
|
||||
} catch (err) {
|
||||
tags.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activeTag.value = route.query.tag || ''
|
||||
load()
|
||||
loadTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.gl-new {
|
||||
font-size: 0.82rem; padding: 0.35rem 0.8rem; border-radius: 4px;
|
||||
background: var(--primary); color: #fff; text-decoration: none;
|
||||
}
|
||||
.gl-filters { margin: 0.8rem 0 1.2rem; }
|
||||
.gl-search {
|
||||
width: 100%; max-width: 34rem; padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
.gl-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
|
||||
.gl-tag {
|
||||
font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); cursor: pointer;
|
||||
}
|
||||
.gl-tagon { border-color: var(--primary); color: var(--primary); }
|
||||
.gl-count { opacity: 0.6; }
|
||||
|
||||
.gl-grid {
|
||||
display: grid; gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
|
||||
}
|
||||
.gl-card {
|
||||
display: block; padding: 0.9rem 1rem; border: 1px solid var(--border);
|
||||
border-radius: 6px; text-decoration: none; color: inherit;
|
||||
background: var(--surface, transparent);
|
||||
}
|
||||
.gl-card:hover { border-color: var(--primary); }
|
||||
.gl-cardhead { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.gl-cardhead h3 { margin: 0; font-size: 1rem; flex: 1; }
|
||||
.gl-draft {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 3px;
|
||||
border: 1px solid var(--warning, #d9822b); color: var(--warning, #d9822b);
|
||||
}
|
||||
.gl-summary { margin: 0.4rem 0 0.6rem; font-size: 0.85rem; color: var(--text-light); }
|
||||
.gl-cardfoot { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
|
||||
.gl-minitag {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 999px;
|
||||
background: var(--surface-2, rgba(127,127,127,0.1)); color: var(--text-light);
|
||||
}
|
||||
.gl-checks { font-size: 0.7rem; color: var(--text-light); margin-left: auto; }
|
||||
.gl-empty, .gl-loading { color: var(--text-light); padding: 1.5rem 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user