- B: token-equivalent rgba → color-mix(srgb, var(--ember|green|candle) X%, transparent) so colors track dark mode - C: drop stale var(--green, #...) fallbacks (canonical token now defined in main.css) - F: inline circle badge → <CircleBadge/> in admin/index, members/[id], members/index
1499 lines
41 KiB
Vue
1499 lines
41 KiB
Vue
<template>
|
|
<div class="admin-wiki">
|
|
<!-- Page Header -->
|
|
<div class="page-header">
|
|
<div class="header-row">
|
|
<div>
|
|
<h1>Wiki</h1>
|
|
<p v-if="articles">
|
|
{{ articles.length }} articles
|
|
<template v-if="collections.length"> across {{ collections.length }} collections</template>
|
|
</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button
|
|
class="btn"
|
|
:disabled="syncing"
|
|
@click="syncFromOutline"
|
|
>
|
|
{{ syncing ? 'Syncing...' : 'Sync from Outline' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sync Results -->
|
|
<div v-if="syncResults" class="sync-results">
|
|
<span v-if="syncResults.created" class="sync-stat sync-created">{{ syncResults.created }} created</span>
|
|
<span v-if="syncResults.updated" class="sync-stat sync-updated">{{ syncResults.updated }} updated</span>
|
|
<span v-if="syncResults.deleted" class="sync-stat sync-deleted">{{ syncResults.deleted }} removed</span>
|
|
<span v-if="syncResults.errors" class="sync-stat sync-errors">{{ syncResults.errors }} errors</span>
|
|
<span v-if="!syncResults.created && !syncResults.updated && !syncResults.deleted && !syncResults.errors" class="sync-stat">Already up to date</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter Bar -->
|
|
<div class="filter-bar">
|
|
<div class="field" style="margin-bottom: 0; flex: 1">
|
|
<input
|
|
v-model="searchQuery"
|
|
placeholder="Search articles by title..."
|
|
/>
|
|
</div>
|
|
<div class="field" style="margin-bottom: 0">
|
|
<select v-model="collectionFilter" aria-label="Filter by collection">
|
|
<option value="">All Collections</option>
|
|
<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 -->
|
|
<div v-if="selectedIds.length" class="batch-bar">
|
|
<span class="batch-count">{{ selectedIds.length }} selected</span>
|
|
<button
|
|
v-if="collectionFilter && !allInCollectionSelected"
|
|
class="link-btn"
|
|
@click="selectAllInCollection"
|
|
>
|
|
Select all in "{{ collectionFilter }}"
|
|
</button>
|
|
<div class="batch-tag-picker">
|
|
<select v-model="batchTagMode" aria-label="Batch tag mode" class="batch-select batch-mode-select">
|
|
<option value="add">Add</option>
|
|
<option value="remove">Remove</option>
|
|
</select>
|
|
<select v-model="batchTagSelected" :aria-label="batchTagMode === 'add' ? 'Tag to add' : 'Tag to remove'" class="batch-select">
|
|
<option value="">Select tag...</option>
|
|
<option v-for="tag in availableTags" :key="tag.slug" :value="tag.slug">{{ tag.label }}</option>
|
|
</select>
|
|
<button
|
|
class="btn btn-primary"
|
|
:disabled="!batchTagSelected || batchApplying"
|
|
@click="applyBatchTag"
|
|
>
|
|
{{ 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>
|
|
|
|
<!-- Loading / Error -->
|
|
<div v-if="pending" class="loading-state">
|
|
<div class="spinner" />
|
|
<span>Loading articles...</span>
|
|
</div>
|
|
|
|
<div v-else-if="error" class="error-state">
|
|
Error loading articles: {{ error }}
|
|
</div>
|
|
|
|
<!-- Article Table -->
|
|
<div v-else class="table-wrap">
|
|
<!-- 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)"
|
|
>
|
|
<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>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: 'admin',
|
|
})
|
|
|
|
const toast = useToast()
|
|
|
|
// ---- Data Fetching ----
|
|
const {
|
|
data: articles,
|
|
pending,
|
|
error,
|
|
refresh,
|
|
} = await useFetch('/api/admin/wiki')
|
|
|
|
const { data: tagsData } = await useFetch('/api/tags')
|
|
|
|
const availableTags = computed(() => tagsData.value?.tags || [])
|
|
|
|
const tagLabelMap = computed(() => {
|
|
const map = {}
|
|
for (const tag of availableTags.value) {
|
|
map[tag.slug] = tag.label
|
|
}
|
|
return map
|
|
})
|
|
|
|
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')
|
|
|
|
const collections = computed(() => {
|
|
if (!articles.value) return []
|
|
const names = new Set()
|
|
for (const a of articles.value) {
|
|
if (a.collection) names.add(a.collection)
|
|
}
|
|
return [...names].sort()
|
|
})
|
|
|
|
const toggleSort = (key) => {
|
|
if (sortKey.value === key) {
|
|
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
|
} else {
|
|
sortKey.value = key
|
|
sortDir.value = 'asc'
|
|
}
|
|
}
|
|
|
|
const filtered = computed(() => {
|
|
if (!articles.value) return []
|
|
|
|
const result = articles.value.filter((a) => {
|
|
const q = searchQuery.value.toLowerCase()
|
|
const matchesSearch = !q || a.title.toLowerCase().includes(q)
|
|
const matchesCollection = !collectionFilter.value || a.collection === collectionFilter.value
|
|
const matchesVisibility = !visibilityFilter.value
|
|
|| (visibilityFilter.value === 'hidden' && a.hidden)
|
|
|| (visibilityFilter.value === 'visible' && !a.hidden)
|
|
return matchesSearch && matchesCollection && matchesVisibility
|
|
})
|
|
|
|
if (sortKey.value) {
|
|
const dir = sortDir.value === 'asc' ? 1 : -1
|
|
const key = sortKey.value
|
|
result.sort((a, b) => {
|
|
const aVal = (a[key] || '').toString().toLowerCase()
|
|
const bVal = (b[key] || '').toString().toLowerCase()
|
|
return aVal < bVal ? -dir : aVal > bVal ? dir : 0
|
|
})
|
|
}
|
|
|
|
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([])
|
|
|
|
const allVisibleSelected = computed(() => {
|
|
if (!filtered.value.length) return false
|
|
return filtered.value.every((a) => selectedIds.value.includes(a._id))
|
|
})
|
|
|
|
const someVisibleSelected = computed(() => {
|
|
return filtered.value.some((a) => selectedIds.value.includes(a._id))
|
|
})
|
|
|
|
const allInCollectionSelected = computed(() => {
|
|
if (!collectionFilter.value || !articles.value) return false
|
|
const inCollection = articles.value.filter((a) => a.collection === collectionFilter.value)
|
|
return inCollection.every((a) => selectedIds.value.includes(a._id))
|
|
})
|
|
|
|
const toggleSelectAll = () => {
|
|
if (allVisibleSelected.value) {
|
|
const visibleIds = new Set(filtered.value.map((a) => a._id))
|
|
selectedIds.value = selectedIds.value.filter((id) => !visibleIds.has(id))
|
|
} else {
|
|
const currentSet = new Set(selectedIds.value)
|
|
for (const a of filtered.value) {
|
|
currentSet.add(a._id)
|
|
}
|
|
selectedIds.value = [...currentSet]
|
|
}
|
|
}
|
|
|
|
const toggleSelect = (id) => {
|
|
const idx = selectedIds.value.indexOf(id)
|
|
if (idx >= 0) {
|
|
selectedIds.value.splice(idx, 1)
|
|
} else {
|
|
selectedIds.value.push(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)
|
|
for (const a of articles.value) {
|
|
if (a.collection === collectionFilter.value) {
|
|
currentSet.add(a._id)
|
|
}
|
|
}
|
|
selectedIds.value = [...currentSet]
|
|
}
|
|
|
|
// ---- Sync ----
|
|
const syncing = ref(false)
|
|
const syncResults = ref(null)
|
|
|
|
const syncFromOutline = async () => {
|
|
syncing.value = true
|
|
syncResults.value = null
|
|
try {
|
|
const result = await $fetch('/api/admin/wiki/sync', { method: 'POST' })
|
|
syncResults.value = result
|
|
await refresh()
|
|
toast.add({
|
|
title: 'Sync complete',
|
|
description: `${result.created} created, ${result.updated} updated, ${result.deleted} removed`,
|
|
color: result.errors ? 'orange' : 'green',
|
|
})
|
|
} catch (err) {
|
|
toast.add({
|
|
title: 'Sync failed',
|
|
description: err.data?.statusMessage || err.message,
|
|
color: 'red',
|
|
})
|
|
} finally {
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
// ---- Per-Article Tag Editing ----
|
|
const editingId = ref(null)
|
|
const editingTags = ref([])
|
|
const tagToAdd = ref('')
|
|
const savingTags = ref(false)
|
|
|
|
const availableTagsForEditing = computed(() => {
|
|
return availableTags.value.filter((t) => !editingTags.value.includes(t.slug))
|
|
})
|
|
|
|
const startEditing = (article) => {
|
|
editingId.value = article._id
|
|
editingTags.value = [...(article.tags || [])]
|
|
tagToAdd.value = ''
|
|
}
|
|
|
|
const cancelEditing = () => {
|
|
editingId.value = null
|
|
editingTags.value = []
|
|
tagToAdd.value = ''
|
|
}
|
|
|
|
const addTagToEditing = () => {
|
|
if (tagToAdd.value && !editingTags.value.includes(tagToAdd.value)) {
|
|
editingTags.value.push(tagToAdd.value)
|
|
}
|
|
tagToAdd.value = ''
|
|
}
|
|
|
|
const removeTagFromEditing = (tag) => {
|
|
editingTags.value = editingTags.value.filter((t) => t !== tag)
|
|
}
|
|
|
|
const saveArticleTags = async () => {
|
|
savingTags.value = true
|
|
try {
|
|
await $fetch(`/api/admin/wiki/${editingId.value}`, {
|
|
method: 'PATCH',
|
|
body: { tags: editingTags.value },
|
|
})
|
|
await refresh()
|
|
cancelEditing()
|
|
toast.add({ title: 'Tags updated', color: 'green' })
|
|
} catch (err) {
|
|
toast.add({
|
|
title: 'Failed to update tags',
|
|
description: err.data?.statusMessage || err.message,
|
|
color: 'red',
|
|
})
|
|
} finally {
|
|
savingTags.value = false
|
|
}
|
|
}
|
|
|
|
// ---- Batch Tagging ----
|
|
const batchTagMode = ref('add')
|
|
const batchTagSelected = ref('')
|
|
const batchApplying = ref(false)
|
|
|
|
const applyBatchTag = async () => {
|
|
if (!batchTagSelected.value || !selectedIds.value.length) return
|
|
batchApplying.value = true
|
|
try {
|
|
const body = { articleIds: selectedIds.value }
|
|
if (batchTagMode.value === 'add') {
|
|
body.addTags = [batchTagSelected.value]
|
|
} else {
|
|
body.removeTags = [batchTagSelected.value]
|
|
}
|
|
const result = await $fetch('/api/admin/wiki/batch-tag', {
|
|
method: 'POST',
|
|
body,
|
|
})
|
|
await refresh()
|
|
const action = batchTagMode.value === 'add' ? 'added to' : 'removed from'
|
|
toast.add({
|
|
title: `Tag ${action} ${result.modified} articles`,
|
|
color: 'green',
|
|
})
|
|
batchTagSelected.value = ''
|
|
selectedIds.value = []
|
|
} catch (err) {
|
|
toast.add({
|
|
title: 'Batch tag failed',
|
|
description: err.data?.statusMessage || err.message,
|
|
color: 'red',
|
|
})
|
|
} finally {
|
|
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>
|
|
.admin-wiki {}
|
|
|
|
/* ---- PAGE HEADER ---- */
|
|
.page-header {
|
|
padding: 28px 28px 20px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.header-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.page-header h1 {
|
|
font-family: 'Brygada 1918', serif;
|
|
font-size: 24px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
line-height: 1.2;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.page-header p {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ---- SYNC RESULTS ---- */
|
|
.sync-results {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.sync-stat {
|
|
font-size: 11px;
|
|
padding: 2px 8px;
|
|
border: 1px dashed var(--border);
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.sync-created {
|
|
color: var(--green);
|
|
border-color: var(--green);
|
|
}
|
|
|
|
.sync-updated {
|
|
color: var(--candle);
|
|
border-color: var(--candle);
|
|
}
|
|
|
|
.sync-deleted {
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
.sync-errors {
|
|
color: var(--ember);
|
|
border-color: var(--ember);
|
|
}
|
|
|
|
/* ---- FILTER BAR ---- */
|
|
.filter-bar {
|
|
display: flex;
|
|
gap: 12px;
|
|
padding: 16px 28px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
/* ---- BATCH ACTION BAR ---- */
|
|
.batch-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 28px;
|
|
border-bottom: 1px dashed var(--border);
|
|
background: var(--surface);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.batch-count {
|
|
font-size: 12px;
|
|
color: var(--text-bright);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.batch-tag-picker {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.batch-select {
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 12px;
|
|
padding: 4px 8px;
|
|
border: 1px solid var(--border);
|
|
background: var(--input-bg);
|
|
color: var(--text);
|
|
}
|
|
|
|
.batch-select:focus {
|
|
border-color: var(--candle);
|
|
outline: none;
|
|
}
|
|
|
|
.batch-mode-select {
|
|
width: auto;
|
|
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 0 24px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
}
|
|
|
|
thead th {
|
|
text-align: left;
|
|
padding: 12px 10px;
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-faint);
|
|
border-bottom: 1px dashed var(--border);
|
|
font-weight: normal;
|
|
}
|
|
|
|
thead th.sortable {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
thead th.sortable:hover {
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.sort-arrow {
|
|
font-size: 10px;
|
|
color: var(--candle);
|
|
}
|
|
|
|
tbody tr {
|
|
border-bottom: 1px dashed var(--border);
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
tbody tr:hover {
|
|
background: var(--surface);
|
|
}
|
|
|
|
tbody td {
|
|
padding: 10px;
|
|
color: var(--text);
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.col-check {
|
|
width: 40px;
|
|
padding-left: 12px;
|
|
padding-right: 4px;
|
|
}
|
|
|
|
.selectable-row {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.row-selected {
|
|
background: var(--surface);
|
|
}
|
|
|
|
/* ---- CUSTOM CHECKBOX ---- */
|
|
.custom-check {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
position: relative;
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.custom-check input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.check-mark {
|
|
width: 14px;
|
|
height: 14px;
|
|
border: 1px solid var(--border);
|
|
background: var(--input-bg);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
|
|
.custom-check:hover .check-mark {
|
|
border-color: var(--candle);
|
|
}
|
|
|
|
.custom-check input:checked + .check-mark {
|
|
background: var(--candle);
|
|
border-color: var(--candle);
|
|
}
|
|
|
|
.custom-check input:checked + .check-mark::after {
|
|
content: '';
|
|
width: 4px;
|
|
height: 8px;
|
|
border: solid var(--bg);
|
|
border-width: 0 1.5px 1.5px 0;
|
|
transform: rotate(45deg) translateY(-1px);
|
|
}
|
|
|
|
.custom-check input:indeterminate + .check-mark {
|
|
background: var(--candle);
|
|
border-color: var(--candle);
|
|
}
|
|
|
|
.custom-check input:indeterminate + .check-mark::after {
|
|
content: '';
|
|
width: 8px;
|
|
height: 0;
|
|
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;
|
|
color: var(--text-dim);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.col-title {
|
|
min-width: 200px;
|
|
}
|
|
|
|
.article-link {
|
|
color: var(--text-bright);
|
|
font-size: 13px;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.article-link:hover {
|
|
color: var(--candle);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* ---- TAGS ---- */
|
|
.col-tags {
|
|
min-width: 180px;
|
|
}
|
|
|
|
.tag-display {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
}
|
|
|
|
.tag-chip {
|
|
font-size: 10px;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
padding: 2px 6px;
|
|
border: 1px dashed var(--border);
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.tag-chip-editable {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.tag-remove {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-faint);
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
padding: 0 2px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.tag-remove:hover {
|
|
color: var(--ember);
|
|
}
|
|
|
|
.no-tags {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
font-style: italic;
|
|
}
|
|
|
|
/* ---- INLINE TAG EDITING ---- */
|
|
.tag-edit-inline {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.tag-add-select {
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 11px;
|
|
padding: 3px 6px;
|
|
border: 1px solid var(--border);
|
|
background: var(--input-bg);
|
|
color: var(--text);
|
|
max-width: 160px;
|
|
}
|
|
|
|
.tag-add-select:focus {
|
|
border-color: var(--candle);
|
|
outline: none;
|
|
}
|
|
|
|
.editing-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
}
|
|
|
|
.tag-edit-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* ---- ACTIONS ---- */
|
|
.col-actions-head {
|
|
text-align: right;
|
|
}
|
|
|
|
.col-actions {
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.link-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--candle);
|
|
cursor: pointer;
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.link-btn:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.link-btn:disabled {
|
|
color: var(--text-faint);
|
|
cursor: default;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.link-btn-save {
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* ---- STATES ---- */
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--text-faint);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.error-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--ember);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--text-faint);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px dashed var(--candle);
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 12px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* ---- RESPONSIVE ---- */
|
|
@media (max-width: 768px) {
|
|
.page-header {
|
|
padding: 24px 20px 16px;
|
|
}
|
|
|
|
.header-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.filter-bar {
|
|
flex-direction: column;
|
|
padding: 12px 20px;
|
|
}
|
|
|
|
.batch-bar {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
padding: 12px 20px;
|
|
}
|
|
|
|
.batch-tag-picker {
|
|
margin-left: 0;
|
|
}
|
|
|
|
.table-wrap {
|
|
padding: 0 12px 20px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
table {
|
|
min-width: 600px;
|
|
}
|
|
}
|
|
</style>
|