Member/Ecology revamp.
This commit is contained in:
parent
fc7ec52574
commit
59d6e97787
31 changed files with 1763 additions and 1010 deletions
|
|
@ -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">×</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">×</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">×</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">×</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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue