ghostguild-org/app/pages/admin/series-management.vue
Jennie Robinson Faber fb25e72215
Some checks failed
Test / vitest (push) Successful in 10m36s
Test / playwright (push) Failing after 9m23s
Test / visual (push) Failing after 9m13s
Test / Notify on failure (push) Successful in 2s
Huge bunch of UI/UX improvements and tweaks!
2026-04-06 16:17:12 +01:00

1250 lines
35 KiB
Vue

<template>
<div class="admin-series">
<!-- Page Header -->
<div class="page-header">
<div class="header-row">
<div>
<h1>Series</h1>
<p>Manage event series and their relationships</p>
</div>
<div class="header-actions">
<button class="btn" @click="showBulkModal = true">Bulk Operations</button>
<NuxtLink to="/admin/series/create" class="btn btn-primary">Create Series</NuxtLink>
</div>
</div>
</div>
<!-- Stats + Filters row -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Overview</div>
<div class="stat-row">
<span class="stat-key">Active Series</span>
<span class="stat-val">{{ activeSeries.length }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Total Series Events</span>
<span class="stat-val">{{ totalSeriesEvents }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Avg Events/Series</span>
<span class="stat-val">{{ activeSeries.length > 0 ? Math.round(totalSeriesEvents / activeSeries.length) : 0 }}</span>
</div>
</div>
<div class="content-block">
<div class="section-label">Filters</div>
<div class="field">
<label>Search</label>
<input v-model="searchQuery" placeholder="Search series..." />
</div>
<div class="field">
<label>Status</label>
<select v-model="statusFilter">
<option value="">All Status</option>
<option value="active">Active</option>
<option value="upcoming">Upcoming</option>
<option value="completed">Completed</option>
</select>
</div>
</div>
</div>
<!-- Series List -->
<div class="series-list">
<div v-if="pending" class="loading-state">
<div class="spinner" />
<span>Loading series...</span>
</div>
<template v-else-if="filteredSeries.length > 0">
<div v-for="series in filteredSeries" :key="series.id" class="series-card">
<!-- Series Header -->
<div class="series-header">
<div class="series-title-row">
<div>
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
<h2>{{ series.title }}</h2>
<p class="series-desc">{{ series.description }}</p>
</div>
<div class="series-meta">
<span :class="['status-pill', `status-${series.status}`]">{{ series.status }}</span>
<span class="event-count">{{ series.eventCount }} events</span>
</div>
</div>
</div>
<!-- Series Events -->
<div class="series-events">
<div v-for="event in series.events" :key="event.id" class="series-event-row">
<div class="event-pos">{{ event.series?.position || '?' }}</div>
<div class="event-info">
<span class="event-name">{{ event.title }}</span>
<span class="event-date">{{ formatEventDate(event.startDate) }}</span>
</div>
<div class="event-actions">
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
{{ getEventStatus(event) }}
</span>
<NuxtLink :to="`/events/${event.slug || event.id}`" class="link-btn">View</NuxtLink>
<button @click="editEvent(event)" class="link-btn">Edit</button>
<button @click="removeFromSeries(event)" class="link-btn link-btn-danger">Remove</button>
</div>
</div>
</div>
<!-- Series Ticketing Info -->
<div v-if="series.tickets?.enabled" class="series-tickets">
<span class="ticket-label">Series Pass Ticketing Enabled</span>
<span v-if="series.tickets.public?.available" class="ticket-detail">
Public: ${{ series.tickets.public.price || 0 }}
</span>
<span v-if="series.tickets.member?.available" class="ticket-detail">
Members: {{ series.tickets.member.isFree ? 'Free' : `$${series.tickets.member.price || 0}` }}
</span>
<button @click="manageSeriesTickets(series)" class="link-btn">Manage Tickets</button>
</div>
<!-- Series Footer -->
<div class="series-footer">
<span class="date-range">{{ formatDateRange(series.startDate, series.endDate) }}</span>
<div class="series-actions">
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
<button @click="editSeries(series)" class="link-btn">Edit</button>
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
<button @click="duplicateSeries(series)" class="link-btn">Duplicate</button>
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
</div>
</div>
</div>
</template>
<div v-else class="empty-state">
<p>No event series found</p>
<p class="empty-hint">Create events and group them into series to get started</p>
</div>
</div>
<!-- Edit Series Modal -->
<div v-if="editingSeriesId" class="modal-overlay" @click.self="cancelEditSeries">
<div class="modal modal-wide">
<div class="modal-header">
<h2>Edit Series</h2>
<button class="modal-close" @click="cancelEditSeries">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Series Title</label>
<input v-model="editingSeriesData.title" type="text" placeholder="e.g., Co-op Game Dev Workshop Series" />
</div>
<div class="field">
<label>Description</label>
<textarea v-model="editingSeriesData.description" rows="3" placeholder="Brief description of this series"></textarea>
</div>
<div class="field">
<label>Series Type</label>
<select v-model="editingSeriesData.type">
<option value="workshop_series">Workshop Series</option>
<option value="recurring_meetup">Recurring Meetup</option>
<option value="multi_day">Multi-Day Event</option>
<option value="course">Course</option>
</select>
</div>
<div class="field">
<label>Total Events (optional)</label>
<input v-model.number="editingSeriesData.totalEvents" type="number" min="1" placeholder="Leave empty for ongoing series" />
</div>
</div>
<div class="modal-actions">
<button @click="cancelEditSeries" class="btn">Cancel</button>
<button @click="saveSeriesEdit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
<!-- Bulk Operations Modal -->
<div v-if="showBulkModal" class="modal-overlay" @click.self="showBulkModal = false">
<div class="modal modal-wide">
<div class="modal-header">
<h2>Bulk Series Operations</h2>
<button class="modal-close" @click="showBulkModal = false">&times;</button>
</div>
<div class="modal-body">
<div class="section-label">Series Management Tools</div>
<button @click="reorderAllSeries" class="btn bulk-action">
<strong>Auto-Reorder Series</strong>
<span>Fix position numbers based on event dates</span>
</button>
<button @click="validateAllSeries" class="btn bulk-action">
<strong>Validate Series Data</strong>
<span>Check for consistency issues</span>
</button>
<button @click="exportSeriesData" class="btn bulk-action">
<strong>Export Series Data</strong>
<span>Download series information as JSON</span>
</button>
</div>
<div class="modal-actions">
<button @click="showBulkModal = false" class="btn">Close</button>
</div>
</div>
</div>
<!-- Series Ticketing Modal -->
<div v-if="editingTicketsSeriesId" class="modal-overlay" @click.self="cancelTicketsEdit">
<div class="modal modal-xl">
<div class="modal-header">
<div>
<h2>Series Pass Ticketing</h2>
<p class="modal-subtitle">{{ editingTicketsData.title }}</p>
</div>
<button class="modal-close" @click="cancelTicketsEdit">&times;</button>
</div>
<div class="modal-body modal-body-scroll">
<!-- Enable Ticketing -->
<div class="dashed-box no-hover" style="margin-bottom: 16px;">
<label class="check-label">
<input v-model="editingTicketsData.tickets.enabled" type="checkbox" />
<div>
<strong>Enable Series Pass Ticketing</strong>
<span class="help-text">Allow users to purchase a pass for all events in this series</span>
</div>
</label>
</div>
<template v-if="editingTicketsData.tickets.enabled">
<!-- Ticketing Behavior -->
<div class="section-label" style="margin-top: 20px;">Ticketing Behavior</div>
<label class="check-label" style="margin-bottom: 8px;">
<input v-model="editingTicketsData.tickets.requiresSeriesTicket" type="checkbox" />
<div>
<strong>Require Series Pass</strong>
<span class="help-text">Users must buy the series pass; individual event tickets are not available</span>
</div>
</label>
<label class="check-label" style="margin-bottom: 16px;">
<input
v-model="editingTicketsData.tickets.allowIndividualEventTickets"
type="checkbox"
:disabled="editingTicketsData.tickets.requiresSeriesTicket"
/>
<div>
<strong>Allow Individual Event Tickets</strong>
<span class="help-text">Users can attend single events without buying the full series pass</span>
</div>
</label>
<!-- Member Tickets -->
<div class="dashed-box no-hover" style="margin-bottom: 16px;">
<div class="ticket-section-header">
<div class="section-label" style="margin-bottom: 0;">Member Series Pass</div>
<label class="check-inline">
<span>Available</span>
<input v-model="editingTicketsData.tickets.member.available" type="checkbox" />
</label>
</div>
<template v-if="editingTicketsData.tickets.member.available">
<div class="field-row">
<div class="field">
<label>Pass Name</label>
<input v-model="editingTicketsData.tickets.member.name" placeholder="e.g., Member Series Pass" />
</div>
<div class="field">
<label>Price (CAD)</label>
<div class="price-row">
<input
v-model.number="editingTicketsData.tickets.member.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="editingTicketsData.tickets.member.isFree"
/>
<label class="check-inline">
<span>Free</span>
<input v-model="editingTicketsData.tickets.member.isFree" type="checkbox" />
</label>
</div>
</div>
</div>
<div class="field">
<label>Description</label>
<textarea v-model="editingTicketsData.tickets.member.description" rows="2" placeholder="Describe what's included..."></textarea>
</div>
</template>
</div>
<!-- Public Tickets -->
<div class="dashed-box no-hover" style="margin-bottom: 16px;">
<div class="ticket-section-header">
<div class="section-label" style="margin-bottom: 0;">Public Series Pass</div>
<label class="check-inline">
<span>Available</span>
<input v-model="editingTicketsData.tickets.public.available" type="checkbox" />
</label>
</div>
<template v-if="editingTicketsData.tickets.public.available">
<div class="field-row">
<div class="field">
<label>Pass Name</label>
<input v-model="editingTicketsData.tickets.public.name" placeholder="e.g., Series Pass" />
</div>
<div class="field">
<label>Price (CAD)</label>
<input v-model.number="editingTicketsData.tickets.public.price" type="number" min="0" step="0.01" placeholder="0.00" />
</div>
</div>
<div class="field">
<label>Description</label>
<textarea v-model="editingTicketsData.tickets.public.description" rows="2" placeholder="Describe what's included..."></textarea>
</div>
<div class="field-row">
<div class="field">
<label>Quantity Available</label>
<input v-model.number="editingTicketsData.tickets.public.quantity" type="number" min="1" placeholder="Unlimited" />
<span class="field-note">{{ editingTicketsData.tickets.public.sold || 0 }} sold, {{ editingTicketsData.tickets.public.reserved || 0 }} reserved</span>
</div>
<div class="field">
<label>Early Bird Price (optional)</label>
<input v-model.number="editingTicketsData.tickets.public.earlyBirdPrice" type="number" min="0" step="0.01" placeholder="0.00" />
</div>
</div>
<div v-if="editingTicketsData.tickets.public.earlyBirdPrice > 0" class="field">
<label>Early Bird Deadline</label>
<input v-model="editingTicketsData.tickets.public.earlyBirdDeadline" type="datetime-local" />
<span class="field-note">Price increases to ${{ editingTicketsData.tickets.public.price }} after this date</span>
</div>
</template>
</div>
<!-- Capacity -->
<div class="dashed-box no-hover" style="margin-bottom: 16px;">
<div class="section-label">Capacity Management</div>
<div class="field-row">
<div class="field">
<label>Total Capacity</label>
<input v-model.number="editingTicketsData.tickets.capacity.total" type="number" min="1" placeholder="Unlimited" />
<span class="field-note">Maximum series pass holders across all types</span>
</div>
<div class="field">
<label>Currently Reserved</label>
<input v-model.number="editingTicketsData.tickets.capacity.reserved" type="number" min="0" disabled />
<span class="field-note">Auto-calculated during checkout</span>
</div>
</div>
</div>
<!-- Waitlist -->
<div class="dashed-box no-hover">
<div class="ticket-section-header">
<div class="section-label" style="margin-bottom: 0;">Waitlist</div>
<label class="check-inline">
<span>Enable Waitlist</span>
<input v-model="editingTicketsData.tickets.waitlist.enabled" type="checkbox" />
</label>
</div>
<template v-if="editingTicketsData.tickets.waitlist.enabled">
<div class="field">
<label>Max Waitlist Size</label>
<input v-model.number="editingTicketsData.tickets.waitlist.maxSize" type="number" min="1" placeholder="Unlimited" />
<span class="field-note">{{ editingTicketsData.tickets.waitlist.entries?.length || 0 }} people currently on waitlist</span>
</div>
</template>
</div>
</template>
</div>
<div class="modal-actions">
<button @click="cancelTicketsEdit" class="btn">Cancel</button>
<button @click="saveTicketsEdit" class="btn btn-primary">Save Ticketing Settings</button>
</div>
</div>
</div>
<!-- Confirm Action Modal -->
<div v-if="confirmAction.show" class="modal-overlay" @click.self="confirmAction.show = false">
<div class="modal">
<div class="modal-header">
<h2>{{ confirmAction.heading }}</h2>
<button class="modal-close" @click="confirmAction.show = false">&times;</button>
</div>
<div class="modal-body">
<p>{{ confirmAction.message }}</p>
<p class="help-text" style="margin-top: 8px;">This action cannot be undone.</p>
</div>
<div class="modal-actions">
<button class="btn" @click="confirmAction.show = false">Cancel</button>
<button class="btn btn-danger" :disabled="confirmAction.running" @click="confirmAction.execute">
{{ confirmAction.running ? 'Working...' : confirmAction.label }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin',
middleware: 'admin',
})
const showBulkModal = ref(false)
const searchQuery = ref('')
const statusFilter = ref('')
const editingSeriesId = ref(null)
const editingSeriesData = ref({
title: '',
description: '',
type: 'workshop_series',
totalEvents: null,
})
const editingTicketsSeriesId = ref(null)
const editingTicketsData = ref({
title: '',
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: 'CAD',
member: {
available: true,
isFree: true,
price: 0,
name: 'Member Series Pass',
description: '',
},
public: {
available: false,
name: 'Series Pass',
description: '',
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: '',
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
})
const {
data: seriesData,
pending,
refresh,
} = await useFetch('/api/admin/series')
const activeSeries = computed(() => seriesData.value || [])
const totalSeriesEvents = computed(() => {
return activeSeries.value.reduce((sum, series) => sum + (series.eventCount || 0), 0)
})
const filteredSeries = computed(() => {
if (!activeSeries.value) return []
return activeSeries.value.filter((series) => {
const matchesSearch =
!searchQuery.value ||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
series.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesStatus =
!statusFilter.value || series.status === statusFilter.value
return matchesSearch && matchesStatus
})
})
const formatSeriesType = (type) => {
const types = {
workshop_series: 'Workshop Series',
recurring_meetup: 'Recurring Meetup',
multi_day: 'Multi-Day Event',
course: 'Course',
}
return types[type] || type
}
const getSeriesTypeClass = (type) => {
const classes = {
workshop_series: 'all',
recurring_meetup: 'all',
multi_day: 'founder',
course: 'all',
}
return classes[type] || 'all'
}
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates'
const start = new Date(startDate)
const end = new Date(endDate)
const formatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' })
return `${formatter.format(start)} - ${formatter.format(end)}`
}
const getEventStatus = (event) => {
const now = new Date()
const startDate = new Date(event.startDate)
const endDate = new Date(event.endDate)
if (now < startDate) return 'Upcoming'
if (now >= startDate && now <= endDate) return 'Ongoing'
return 'Completed'
}
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`)
}
const confirmAction = reactive({
show: false,
heading: '',
message: '',
label: '',
running: false,
execute: () => {},
})
const removeFromSeries = (event) => {
confirmAction.heading = 'Remove from Series'
confirmAction.message = `Remove "${event.title}" from its series?`
confirmAction.label = 'Remove'
confirmAction.running = false
confirmAction.execute = async () => {
confirmAction.running = true
try {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null,
},
},
})
confirmAction.show = false
await refresh()
} catch (error) {
console.error('Failed to remove event from series:', error)
} finally {
confirmAction.running = false
}
}
confirmAction.show = true
}
const addEventToSeries = (series) => {
const seriesPayload = {
series: {
isSeriesEvent: true,
id: series.id,
title: series.title,
description: series.description,
type: series.type,
position: (series.eventCount || 0) + 1,
totalEvents: series.totalEvents,
},
}
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesPayload))
navigateTo('/admin/events/create?series=true')
}
const duplicateSeries = () => {
// TODO: Implement
}
const editSeries = (series) => {
editingSeriesId.value = series.id
editingSeriesData.value = {
title: series.title,
description: series.description,
type: series.type,
totalEvents: series.totalEvents,
}
}
const cancelEditSeries = () => {
editingSeriesId.value = null
editingSeriesData.value = { title: '', description: '', type: 'workshop_series', totalEvents: null }
}
const saveSeriesEdit = async () => {
if (!editingSeriesData.value.title) return
try {
await $fetch('/api/admin/series', {
method: 'PUT',
body: { id: editingSeriesId.value, ...editingSeriesData.value },
})
await refresh()
cancelEditSeries()
} catch (error) {
console.error('Failed to update series:', error)
}
}
const deleteSeries = (series) => {
confirmAction.heading = 'Delete Series'
confirmAction.message = `Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`
confirmAction.label = 'Delete'
confirmAction.running = false
confirmAction.execute = async () => {
confirmAction.running = true
try {
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: { isSeriesEvent: false, id: '', title: '', description: '', type: 'workshop_series', position: 1, totalEvents: null },
},
})
}
confirmAction.show = false
await refresh()
} catch (error) {
console.error('Failed to delete series:', error)
} finally {
confirmAction.running = false
}
}
confirmAction.show = true
}
const manageSeriesTickets = (series) => {
editingTicketsSeriesId.value = series.id
editingTicketsData.value = {
title: series.title,
tickets: {
enabled: series.tickets?.enabled || false,
requiresSeriesTicket: series.tickets?.requiresSeriesTicket || false,
allowIndividualEventTickets: series.tickets?.allowIndividualEventTickets !== false,
currency: series.tickets?.currency || 'CAD',
member: {
available: series.tickets?.member?.available !== false,
isFree: series.tickets?.member?.isFree !== false,
price: series.tickets?.member?.price || 0,
name: series.tickets?.member?.name || 'Member Series Pass',
description: series.tickets?.member?.description || '',
},
public: {
available: series.tickets?.public?.available || false,
name: series.tickets?.public?.name || 'Series Pass',
description: series.tickets?.public?.description || '',
price: series.tickets?.public?.price || 0,
quantity: series.tickets?.public?.quantity || null,
sold: series.tickets?.public?.sold || 0,
reserved: series.tickets?.public?.reserved || 0,
earlyBirdPrice: series.tickets?.public?.earlyBirdPrice || null,
earlyBirdDeadline: series.tickets?.public?.earlyBirdDeadline
? new Date(series.tickets.public.earlyBirdDeadline).toISOString().slice(0, 16)
: '',
},
capacity: {
total: series.tickets?.capacity?.total || null,
reserved: series.tickets?.capacity?.reserved || 0,
},
waitlist: {
enabled: series.tickets?.waitlist?.enabled || false,
maxSize: series.tickets?.waitlist?.maxSize || null,
entries: series.tickets?.waitlist?.entries || [],
},
},
}
}
const cancelTicketsEdit = () => {
editingTicketsSeriesId.value = null
}
const saveTicketsEdit = async () => {
try {
await $fetch('/api/admin/series/tickets', {
method: 'PUT',
body: { id: editingTicketsSeriesId.value, tickets: editingTicketsData.value.tickets },
})
await refresh()
cancelTicketsEdit()
} catch (error) {
console.error('Failed to update ticketing settings:', error)
}
}
const reorderAllSeries = () => { /* TODO */ }
const validateAllSeries = () => { /* TODO */ }
const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = 'event-series-data.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.admin-series {}
/* ---- 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;
}
/* ---- CONTENT GRID ---- */
.content-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: 1px dashed var(--border);
}
.content-block {
padding: 24px 28px;
border-right: 1px dashed var(--border);
min-width: 0;
}
.content-block:last-child {
border-right: none;
}
/* ---- STATS ---- */
.stat-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.stat-row:last-of-type {
border-bottom: none;
}
.stat-key { color: var(--text-faint); }
.stat-val { color: var(--text-bright); font-weight: 600; }
/* ---- SERIES LIST ---- */
.series-list {
padding: 24px 28px;
}
.series-card {
border: 1px dashed var(--border);
margin-bottom: 20px;
transition: border-color 0.2s;
}
.series-card:hover {
border-color: var(--candle-faint);
}
.series-header {
padding: 20px 24px 16px;
border-bottom: 1px dashed var(--border);
}
.series-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.series-header h2 {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
margin: 8px 0 2px;
}
.series-desc {
font-size: 12px;
color: var(--text-dim);
}
.series-meta {
text-align: right;
flex-shrink: 0;
}
.event-count {
display: block;
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}
/* ---- SERIES EVENTS ---- */
.series-events {
border-bottom: 1px dashed var(--border);
}
.series-event-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 24px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.series-event-row:last-child {
border-bottom: none;
}
.series-event-row:hover {
background: var(--surface);
}
.event-pos {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: var(--c-founder);
border: 1px dashed rgba(138, 68, 32, 0.4);
border-radius: 50%;
flex-shrink: 0;
}
.event-info {
flex: 1;
min-width: 0;
}
.event-name {
display: block;
color: var(--text);
font-weight: 500;
}
.event-date {
display: block;
font-size: 11px;
color: var(--text-faint);
margin-top: 1px;
}
.event-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* ---- SERIES TICKETS BAR ---- */
.series-tickets {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 24px;
background: var(--surface);
border-bottom: 1px dashed var(--border);
font-size: 11px;
}
.ticket-label {
color: var(--candle);
font-weight: 500;
}
.ticket-detail {
color: var(--text-faint);
}
/* ---- SERIES FOOTER ---- */
.series-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 24px;
background: var(--surface);
}
.date-range {
font-size: 11px;
color: var(--text-faint);
}
.series-actions {
display: flex;
gap: 4px;
}
/* ---- STATUS PILLS ---- */
.status-pill {
display: inline-block;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 8px;
border: 1px dashed;
}
.status-active {
color: var(--green);
border-color: rgba(74, 106, 56, 0.3);
}
.status-upcoming {
color: var(--candle);
border-color: rgba(122, 90, 16, 0.3);
}
.status-completed {
color: var(--text-faint);
border-color: var(--border);
}
.status-ongoing {
color: var(--green);
border-color: rgba(74, 106, 56, 0.3);
}
/* ---- LINK BUTTONS ---- */
.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-danger { color: var(--ember); }
/* ---- MODALS ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
background: var(--bg);
border: 1px dashed var(--border);
max-width: 440px;
width: 100%;
margin: 16px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-wide { max-width: 640px; }
.modal-xl { max-width: 800px; }
.modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 20px 24px 16px;
border-bottom: 1px dashed var(--border);
}
.modal-header h2 {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
}
.modal-subtitle {
font-size: 12px;
color: var(--text-dim);
margin-top: 2px;
}
.modal-close {
background: none;
border: none;
color: var(--text-faint);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.modal-close:hover { color: var(--text); }
.modal-body {
padding: 20px 24px;
overflow-y: auto;
}
.modal-body-scroll {
max-height: 60vh;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px dashed var(--border);
}
/* ---- BULK ACTION ---- */
.bulk-action {
display: block;
width: 100%;
text-align: left;
padding: 14px 20px;
border: 1px dashed var(--border);
background: none;
cursor: pointer;
margin-bottom: 8px;
transition: border-color 0.2s;
font-family: 'Commit Mono', monospace;
}
.bulk-action:hover {
border-color: var(--candle-faint);
}
.bulk-action strong {
display: block;
font-size: 13px;
color: var(--text);
font-weight: 500;
margin-bottom: 2px;
}
.bulk-action span {
font-size: 11px;
color: var(--text-faint);
}
/* ---- TICKETING FORM ---- */
.ticket-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.check-label {
display: flex;
gap: 10px;
align-items: flex-start;
cursor: pointer;
}
.check-label input[type="checkbox"] {
margin-top: 2px;
accent-color: var(--candle);
}
.check-label strong {
display: block;
font-size: 13px;
color: var(--text);
font-weight: 500;
}
.check-inline {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-faint);
cursor: pointer;
}
.check-inline input[type="checkbox"] {
accent-color: var(--candle);
}
.help-text {
font-size: 11px;
color: var(--text-dim);
line-height: 1.5;
display: block;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field-note {
display: block;
font-size: 10px;
color: var(--text-faint);
margin-top: 3px;
}
.price-row {
display: flex;
align-items: center;
gap: 8px;
}
.price-row input {
flex: 1;
}
/* ---- STATES ---- */
.loading-state {
text-align: center;
padding: 48px 24px;
color: var(--text-faint);
font-size: 12px;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-faint);
font-size: 12px;
}
.empty-hint {
font-size: 11px;
margin-top: 4px;
}
.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;
}
.content-row {
grid-template-columns: 1fr;
}
.content-block {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.content-block:last-child {
border-bottom: none;
}
.series-list {
padding: 20px 20px;
}
.series-header,
.series-event-row,
.series-tickets,
.series-footer {
padding-left: 16px;
padding-right: 16px;
}
.series-title-row {
flex-direction: column;
}
.series-footer {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.series-actions {
flex-wrap: wrap;
}
.field-row {
grid-template-columns: 1fr;
}
.event-actions {
flex-wrap: wrap;
}
}
</style>