Member/Ecology revamp.
Some checks failed
Test / vitest (push) Failing after 7m23s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s

This commit is contained in:
Jennie Robinson Faber 2026-04-14 09:25:09 +01:00
parent fc7ec52574
commit 59d6e97787
31 changed files with 1763 additions and 1010 deletions

View file

@ -45,6 +45,20 @@
<option v-for="col in collections" :key="col" :value="col">{{ col }}</option>
</select>
</div>
<div class="field" style="margin-bottom: 0">
<select v-model="visibilityFilter" aria-label="Filter by visibility">
<option value="">All</option>
<option value="visible">Visible</option>
<option value="hidden">Hidden</option>
</select>
</div>
<div class="field" style="margin-bottom: 0">
<select v-model="groupBy" aria-label="Group by">
<option value="collection">Group by Collection</option>
<option value="tag">Group by Tag</option>
<option value="none">No Grouping</option>
</select>
</div>
</div>
<!-- Batch Action Bar -->
@ -74,6 +88,22 @@
{{ batchApplying ? 'Applying...' : 'Apply' }}
</button>
</div>
<div class="batch-visibility">
<button
class="btn btn-sm"
:disabled="batchVisibilityApplying"
@click="applyBatchVisibility(true)"
>
Hide
</button>
<button
class="btn btn-sm"
:disabled="batchVisibilityApplying"
@click="applyBatchVisibility(false)"
>
Show
</button>
</div>
<button class="link-btn" @click="selectedIds = []">Clear selection</button>
</div>
@ -89,109 +119,390 @@
<!-- Article Table -->
<div v-else class="table-wrap">
<table v-if="filtered.length">
<thead>
<tr>
<th class="col-check">
<label class="custom-check" aria-label="Select all">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
<span class="check-mark" />
</label>
</th>
<th class="sortable" @click="toggleSort('collection')">
Collection
<span v-if="sortKey === 'collection'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="sortable col-title" @click="toggleSort('title')">
Title
<span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th>Tags</th>
<th class="col-actions-head">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="article in filtered"
:key="article._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(article._id) }"
@click="toggleSelect(article._id)"
>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${article.title}`">
<input
type="checkbox"
:checked="selectedIds.includes(article._id)"
@change="toggleSelect(article._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-collection">{{ article.collection || '—' }}</td>
<td class="col-title">
<a :href="article.url" target="_blank" rel="noopener" class="article-link">
{{ article.title }}
</a>
</td>
<td class="col-tags" @click.stop>
<div v-if="editingId !== article._id" class="tag-display">
<span v-for="tag in article.tags" :key="tag" class="tag-chip">{{ tagLabel(tag) }}</span>
<span v-if="!article.tags?.length" class="no-tags">no tags</span>
</div>
<div v-else class="tag-edit-inline">
<select
v-model="tagToAdd"
aria-label="Add tag"
class="tag-add-select"
@change="addTagToEditing"
>
<option value="">Add...</option>
<option
v-for="tag in availableTagsForEditing"
:key="tag.slug"
:value="tag.slug"
>{{ tag.label }}</option>
</select>
<div class="editing-tags">
<span
v-for="tag in editingTags"
:key="tag"
class="tag-chip tag-chip-editable"
>
{{ tagLabel(tag) }}
<button class="tag-remove" @click="removeTagFromEditing(tag)" aria-label="Remove tag">&times;</button>
</span>
</div>
<div class="tag-edit-actions">
<button class="link-btn" @click="cancelEditing">Cancel</button>
<button class="link-btn link-btn-save" :disabled="savingTags" @click="saveArticleTags">
{{ savingTags ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</td>
<td class="col-actions" @click.stop>
<button
v-if="editingId !== article._id"
class="link-btn"
@click="startEditing(article)"
<!-- Grouped view -->
<template v-if="groupBy !== 'none' && visibleGroups.length">
<details
v-for="group in visibleGroups"
:key="group.name"
class="collection-group"
>
<summary class="collection-header">
<span class="collection-name">{{ group.name }}</span>
<span class="collection-count">{{ group.articles.length }}</span>
</summary>
<table>
<thead>
<tr>
<th class="col-check">
<label class="custom-check" aria-label="Select all in group">
<input
type="checkbox"
:checked="allInGroupSelected(group.articles)"
:indeterminate="!allInGroupSelected(group.articles) && someInGroupSelected(group.articles)"
@change="toggleSelectGroup(group.articles)"
/>
<span class="check-mark" />
</label>
</th>
<th class="col-title sortable" @click="toggleSort('title')">
Title <span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th>Tags</th>
<th class="col-vis sortable" @click="toggleSort('hidden')">
Vis <span v-if="sortKey === 'hidden'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-updated sortable" @click="toggleSort('outlineUpdatedAt')">
Updated <span v-if="sortKey === 'outlineUpdatedAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-actions-head">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="article in group.articles"
:key="article._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(article._id), 'row-hidden': article.hidden }"
@click="toggleSelect(article._id)"
>
Edit tags
</button>
</td>
</tr>
</tbody>
</table>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${article.title}`">
<input
type="checkbox"
:checked="selectedIds.includes(article._id)"
@change="toggleSelect(article._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-title">
<a :href="article.url" target="_blank" rel="noopener" class="article-link" :class="{ 'article-hidden': article.hidden }">
{{ article.title }}
</a>
</td>
<td class="col-tags" @click.stop>
<div v-if="editingId !== article._id" class="tag-display">
<span v-for="tag in article.tags" :key="tag" class="tag-chip">{{ tagLabel(tag) }}</span>
<span v-if="!article.tags?.length" class="no-tags">no tags</span>
</div>
<div v-else class="tag-edit-inline">
<select
v-model="tagToAdd"
aria-label="Add tag"
class="tag-add-select"
@change="addTagToEditing"
>
<option value="">Add...</option>
<option
v-for="tag in availableTagsForEditing"
:key="tag.slug"
:value="tag.slug"
>{{ tag.label }}</option>
</select>
<div class="editing-tags">
<span
v-for="tag in editingTags"
:key="tag"
class="tag-chip tag-chip-editable"
>
{{ tagLabel(tag) }}
<button class="tag-remove" @click="removeTagFromEditing(tag)" aria-label="Remove tag">&times;</button>
</span>
</div>
<div class="tag-edit-actions">
<button class="link-btn" @click="cancelEditing">Cancel</button>
<button class="link-btn link-btn-save" :disabled="savingTags" @click="saveArticleTags">
{{ savingTags ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</td>
<td class="col-vis" @click.stop>
<button
class="vis-toggle"
:class="{ 'is-hidden': article.hidden }"
:title="article.hidden ? 'Hidden — click to show' : 'Visible — click to hide'"
@click="toggleArticleVisibility(article)"
>
{{ article.hidden ? '○' : '●' }}
</button>
</td>
<td class="col-updated">
{{ formatDate(article.outlineUpdatedAt) }}
</td>
<td class="col-actions" @click.stop>
<button
v-if="editingId !== article._id"
class="link-btn"
@click="startEditing(article)"
>
Edit tags
</button>
</td>
</tr>
</tbody>
</table>
</details>
</template>
<!-- Flat view (no grouping) -->
<template v-else-if="groupBy === 'none' && filtered.length">
<table>
<thead>
<tr>
<th class="col-check">
<label class="custom-check" aria-label="Select all">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
<span class="check-mark" />
</label>
</th>
<th class="col-title sortable" @click="toggleSort('title')">
Title <span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="sortable" @click="toggleSort('collection')">
Collection <span v-if="sortKey === 'collection'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th>Tags</th>
<th class="col-vis sortable" @click="toggleSort('hidden')">
Vis <span v-if="sortKey === 'hidden'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-updated sortable" @click="toggleSort('outlineUpdatedAt')">
Updated <span v-if="sortKey === 'outlineUpdatedAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-actions-head">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="article in filtered"
:key="article._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(article._id), 'row-hidden': article.hidden }"
@click="toggleSelect(article._id)"
>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${article.title}`">
<input
type="checkbox"
:checked="selectedIds.includes(article._id)"
@change="toggleSelect(article._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-title">
<a :href="article.url" target="_blank" rel="noopener" class="article-link" :class="{ 'article-hidden': article.hidden }">
{{ article.title }}
</a>
</td>
<td class="col-collection">{{ article.collection || '—' }}</td>
<td class="col-tags" @click.stop>
<div v-if="editingId !== article._id" class="tag-display">
<span v-for="tag in article.tags" :key="tag" class="tag-chip">{{ tagLabel(tag) }}</span>
<span v-if="!article.tags?.length" class="no-tags">no tags</span>
</div>
<div v-else class="tag-edit-inline">
<select
v-model="tagToAdd"
aria-label="Add tag"
class="tag-add-select"
@change="addTagToEditing"
>
<option value="">Add...</option>
<option
v-for="tag in availableTagsForEditing"
:key="tag.slug"
:value="tag.slug"
>{{ tag.label }}</option>
</select>
<div class="editing-tags">
<span
v-for="tag in editingTags"
:key="tag"
class="tag-chip tag-chip-editable"
>
{{ tagLabel(tag) }}
<button class="tag-remove" @click="removeTagFromEditing(tag)" aria-label="Remove tag">&times;</button>
</span>
</div>
<div class="tag-edit-actions">
<button class="link-btn" @click="cancelEditing">Cancel</button>
<button class="link-btn link-btn-save" :disabled="savingTags" @click="saveArticleTags">
{{ savingTags ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</td>
<td class="col-vis" @click.stop>
<button
class="vis-toggle"
:class="{ 'is-hidden': article.hidden }"
:title="article.hidden ? 'Hidden — click to show' : 'Visible — click to hide'"
@click="toggleArticleVisibility(article)"
>
{{ article.hidden ? '○' : '●' }}
</button>
</td>
<td class="col-updated">
{{ formatDate(article.outlineUpdatedAt) }}
</td>
<td class="col-actions" @click.stop>
<button
v-if="editingId !== article._id"
class="link-btn"
@click="startEditing(article)"
>
Edit tags
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="empty-state">
No articles found matching your criteria
</div>
<!-- Hidden collections drawer -->
<details v-if="groupBy !== 'none' && hiddenGroups.length" class="hidden-collections-drawer">
<summary class="hidden-collections-header">
<span>Hidden Collections</span>
<span class="collection-count">{{ hiddenGroups.length }}</span>
</summary>
<details
v-for="group in hiddenGroups"
:key="group.name"
class="collection-group hidden-collection"
>
<summary class="collection-header">
<span class="collection-name">{{ group.name }}</span>
<span class="collection-count">{{ group.articles.length }} articles</span>
<button
class="link-btn show-collection-btn"
@click.stop="showCollection(group)"
>
Show Collection
</button>
</summary>
<table>
<thead>
<tr>
<th class="col-check">
<label class="custom-check" aria-label="Select all in group">
<input
type="checkbox"
:checked="allInGroupSelected(group.articles)"
:indeterminate="!allInGroupSelected(group.articles) && someInGroupSelected(group.articles)"
@change="toggleSelectGroup(group.articles)"
/>
<span class="check-mark" />
</label>
</th>
<th class="col-title sortable" @click="toggleSort('title')">
Title <span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th>Tags</th>
<th class="col-vis sortable" @click="toggleSort('hidden')">
Vis <span v-if="sortKey === 'hidden'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-updated sortable" @click="toggleSort('outlineUpdatedAt')">
Updated <span v-if="sortKey === 'outlineUpdatedAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-actions-head">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="article in group.articles"
:key="article._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(article._id), 'row-hidden': article.hidden }"
@click="toggleSelect(article._id)"
>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${article.title}`">
<input
type="checkbox"
:checked="selectedIds.includes(article._id)"
@change="toggleSelect(article._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-title">
<a :href="article.url" target="_blank" rel="noopener" class="article-link article-hidden">
{{ article.title }}
</a>
</td>
<td class="col-tags" @click.stop>
<div v-if="editingId !== article._id" class="tag-display">
<span v-for="tag in article.tags" :key="tag" class="tag-chip">{{ tagLabel(tag) }}</span>
<span v-if="!article.tags?.length" class="no-tags">no tags</span>
</div>
<div v-else class="tag-edit-inline">
<select
v-model="tagToAdd"
aria-label="Add tag"
class="tag-add-select"
@change="addTagToEditing"
>
<option value="">Add...</option>
<option
v-for="tag in availableTagsForEditing"
:key="tag.slug"
:value="tag.slug"
>{{ tag.label }}</option>
</select>
<div class="editing-tags">
<span
v-for="tag in editingTags"
:key="tag"
class="tag-chip tag-chip-editable"
>
{{ tagLabel(tag) }}
<button class="tag-remove" @click="removeTagFromEditing(tag)" aria-label="Remove tag">&times;</button>
</span>
</div>
<div class="tag-edit-actions">
<button class="link-btn" @click="cancelEditing">Cancel</button>
<button class="link-btn link-btn-save" :disabled="savingTags" @click="saveArticleTags">
{{ savingTags ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</td>
<td class="col-vis" @click.stop>
<button
class="vis-toggle is-hidden"
title="Hidden — click to show"
@click="toggleArticleVisibility(article)"
>
</button>
</td>
<td class="col-updated">
{{ formatDate(article.outlineUpdatedAt) }}
</td>
<td class="col-actions" @click.stop>
<button
v-if="editingId !== article._id"
class="link-btn"
@click="startEditing(article)"
>
Edit tags
</button>
</td>
</tr>
</tbody>
</table>
</details>
</details>
</div>
</div>
</template>
@ -229,6 +540,8 @@ const tagLabel = (slug) => tagLabelMap.value[slug] || slug
// ---- Filters & Sort ----
const searchQuery = ref('')
const collectionFilter = ref('')
const visibilityFilter = ref('')
const groupBy = ref('collection')
const sortKey = ref('')
const sortDir = ref('asc')
@ -257,7 +570,10 @@ const filtered = computed(() => {
const q = searchQuery.value.toLowerCase()
const matchesSearch = !q || a.title.toLowerCase().includes(q)
const matchesCollection = !collectionFilter.value || a.collection === collectionFilter.value
return matchesSearch && matchesCollection
const matchesVisibility = !visibilityFilter.value
|| (visibilityFilter.value === 'hidden' && a.hidden)
|| (visibilityFilter.value === 'visible' && !a.hidden)
return matchesSearch && matchesCollection && matchesVisibility
})
if (sortKey.value) {
@ -273,6 +589,58 @@ const filtered = computed(() => {
return result
})
const groupedFiltered = computed(() => {
if (groupBy.value === 'none') return []
const groups = new Map()
if (groupBy.value === 'tag') {
for (const article of filtered.value) {
if (!article.tags?.length) {
const key = 'Untagged'
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(article)
} else {
for (const tag of article.tags) {
const key = tagLabel(tag)
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(article)
}
}
}
} else {
for (const article of filtered.value) {
const key = article.collection || 'Uncategorized'
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(article)
}
}
return [...groups.entries()]
.map(([name, articles]) => ({ name, articles }))
.sort((a, b) => a.name.localeCompare(b.name))
})
const visibleGroups = computed(() => {
if (visibilityFilter.value === 'hidden') return groupedFiltered.value
return groupedFiltered.value.filter((group) =>
group.articles.some((a) => !a.hidden),
)
})
const hiddenGroups = computed(() => {
if (visibilityFilter.value) return []
return groupedFiltered.value.filter((group) =>
group.articles.every((a) => a.hidden),
)
})
const formatDate = (dateStr) => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
// ---- Selection ----
const selectedIds = ref([])
@ -313,6 +681,28 @@ const toggleSelect = (id) => {
}
}
const allInGroupSelected = (groupArticles) => {
if (!groupArticles.length) return false
return groupArticles.every((a) => selectedIds.value.includes(a._id))
}
const someInGroupSelected = (groupArticles) => {
return groupArticles.some((a) => selectedIds.value.includes(a._id))
}
const toggleSelectGroup = (groupArticles) => {
if (allInGroupSelected(groupArticles)) {
const groupIds = new Set(groupArticles.map((a) => a._id))
selectedIds.value = selectedIds.value.filter((id) => !groupIds.has(id))
} else {
const currentSet = new Set(selectedIds.value)
for (const a of groupArticles) {
currentSet.add(a._id)
}
selectedIds.value = [...currentSet]
}
}
const selectAllInCollection = () => {
if (!collectionFilter.value || !articles.value) return
const currentSet = new Set(selectedIds.value)
@ -442,6 +832,73 @@ const applyBatchTag = async () => {
batchApplying.value = false
}
}
// ---- Visibility Toggle ----
const toggleArticleVisibility = async (article) => {
try {
await $fetch(`/api/admin/wiki/${article._id}`, {
method: 'PATCH',
body: { hidden: !article.hidden },
})
await refresh()
} catch (err) {
toast.add({
title: 'Failed to update visibility',
description: err.data?.statusMessage || err.message,
color: 'red',
})
}
}
// ---- Show Entire Collection ----
const showCollection = async (group) => {
const ids = group.articles.map((a) => a._id)
try {
await $fetch('/api/admin/wiki/batch-visibility', {
method: 'POST',
body: { articleIds: ids, hidden: false },
})
await refresh()
toast.add({
title: `${group.name} is now visible`,
color: 'green',
})
} catch (err) {
toast.add({
title: 'Failed to show collection',
description: err.data?.statusMessage || err.message,
color: 'red',
})
}
}
// ---- Batch Visibility ----
const batchVisibilityApplying = ref(false)
const applyBatchVisibility = async (hidden) => {
if (!selectedIds.value.length) return
batchVisibilityApplying.value = true
try {
const result = await $fetch('/api/admin/wiki/batch-visibility', {
method: 'POST',
body: { articleIds: selectedIds.value, hidden },
})
await refresh()
toast.add({
title: `${result.modified} articles ${hidden ? 'hidden' : 'shown'}`,
color: 'green',
})
selectedIds.value = []
} catch (err) {
toast.add({
title: 'Batch visibility failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
} finally {
batchVisibilityApplying.value = false
}
}
</script>
<style scoped>
@ -566,9 +1023,109 @@ const applyBatchTag = async () => {
min-width: 80px;
}
/* ---- COLLECTION GROUPS ---- */
.collection-group {
border-bottom: 1px dashed var(--border);
}
.collection-group:last-child {
border-bottom: none;
}
.collection-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
cursor: pointer;
list-style: none;
user-select: none;
border-bottom: 1px dashed var(--border);
transition: background 0.1s;
}
.collection-header:hover {
background: var(--surface);
}
.collection-header::-webkit-details-marker {
display: none;
}
.collection-header::before {
content: "▸";
font-size: 11px;
color: var(--text-faint);
transition: transform 0.15s;
}
.collection-group[open] > .collection-header::before {
content: "▾";
}
.collection-name {
font-size: 13px;
font-weight: 500;
color: var(--text-bright);
}
.collection-count {
font-size: 11px;
color: var(--text-faint);
}
.col-updated {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
width: 100px;
}
/* ---- HIDDEN COLLECTIONS DRAWER ---- */
.hidden-collections-drawer {
margin-top: 24px;
border-top: 1px dashed var(--border);
}
.hidden-collections-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
cursor: pointer;
list-style: none;
user-select: none;
font-size: 12px;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hidden-collections-header::-webkit-details-marker {
display: none;
}
.hidden-collections-header::before {
content: "▸";
font-size: 11px;
color: var(--text-faint);
}
.hidden-collections-drawer[open] > .hidden-collections-header::before {
content: "▾";
}
.hidden-collection .collection-header {
padding-left: 44px;
}
.show-collection-btn {
margin-left: auto;
}
/* ---- TABLE ---- */
.table-wrap {
padding: 0 28px 24px;
padding: 0 0 24px;
}
table {
@ -690,6 +1247,49 @@ tbody td {
border-bottom: 1.5px solid var(--bg);
}
/* ---- VISIBILITY ---- */
.col-vis {
width: 50px;
text-align: center;
}
.vis-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--candle);
padding: 2px 6px;
line-height: 1;
}
.vis-toggle.is-hidden {
color: var(--text-faint);
}
.vis-toggle:hover {
opacity: 0.7;
}
.row-hidden {
opacity: 0.5;
}
.article-hidden {
text-decoration: line-through;
color: var(--text-faint);
}
.batch-visibility {
display: flex;
gap: 6px;
}
.btn-sm {
font-size: 11px;
padding: 3px 10px;
}
/* ---- COLUMNS ---- */
.col-collection {
font-size: 11px;