Huge bunch of UI/UX improvements and tweaks!
|
|
@ -45,7 +45,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
- name: Wait for server
|
- name: Wait for server
|
||||||
run: npx wait-on http://localhost:3000 --timeout 30000
|
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
|
||||||
- run: npx playwright test --ignore-snapshots
|
- run: npx playwright test --ignore-snapshots
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|
@ -56,6 +56,18 @@ jobs:
|
||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
notify:
|
||||||
|
name: Notify on failure
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [vitest, playwright]
|
||||||
|
if: failure()
|
||||||
|
steps:
|
||||||
|
- name: Post to Slack
|
||||||
|
run: |
|
||||||
|
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||||
|
-H 'Content-type: application/json' \
|
||||||
|
--data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}"
|
||||||
|
|
||||||
visual:
|
visual:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: vitest
|
needs: vitest
|
||||||
|
|
@ -84,7 +96,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
- name: Wait for server
|
- name: Wait for server
|
||||||
run: npx wait-on http://localhost:3000 --timeout 30000
|
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
|
||||||
- run: npx playwright test e2e/visual/
|
- run: npx playwright test e2e/visual/
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,11 @@
|
||||||
--candle: #d4a03a;
|
--candle: #d4a03a;
|
||||||
--candle-dim: #b8922e;
|
--candle-dim: #b8922e;
|
||||||
--candle-faint: #8a7030;
|
--candle-faint: #8a7030;
|
||||||
--ember: #c06030;
|
--ember: #ca6a3a;
|
||||||
--text: #a89880;
|
--text: #a89880;
|
||||||
--text-bright: #d0c8b0;
|
--text-bright: #d0c8b0;
|
||||||
--text-dim: #8a7e6a;
|
--text-dim: #958774;
|
||||||
--text-faint: #5a5040;
|
--text-faint: #8b7b62;
|
||||||
--parch: #ede4d0;
|
--parch: #ede4d0;
|
||||||
--parch-hover: #d4c8a8;
|
--parch-hover: #d4c8a8;
|
||||||
--parch-text: #2a2015;
|
--parch-text: #2a2015;
|
||||||
|
|
@ -62,7 +62,9 @@
|
||||||
--c-community: #a06850;
|
--c-community: #a06850;
|
||||||
--c-founder: #c06030;
|
--c-founder: #c06030;
|
||||||
--c-practitioner: #4a7080;
|
--c-practitioner: #4a7080;
|
||||||
--ember-bg: rgba(192, 96, 48, 0.14);
|
--green: #6e9c52;
|
||||||
|
--green-bg: rgba(110, 156, 82, 0.12);
|
||||||
|
--ember-bg: rgba(202, 106, 58, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- TAILWIND @THEME MAPPING ---- */
|
/* ---- TAILWIND @THEME MAPPING ---- */
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,15 @@ watch(isOpen, (newValue) => {
|
||||||
loginError.value = ''
|
loginError.value = ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape' && isOpen.value) {
|
||||||
|
resetAndClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -214,12 +214,14 @@ const currentPageName = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
padding: 24px 24px 16px;
|
padding: 0 24px;
|
||||||
|
height: 53px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -447,7 +447,7 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeAgendaItem(index)"
|
@click="removeAgendaItem(index)"
|
||||||
class="agenda-remove"
|
class="link-btn link-btn-danger"
|
||||||
>
|
>
|
||||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -949,7 +949,6 @@ const saveAndCreateAnother = async () => {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.create-form {
|
.create-form {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -972,15 +971,17 @@ const saveAndCreateAnother = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--candle);
|
color: var(--text-faint);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link:hover {
|
.back-link:hover {
|
||||||
text-decoration: underline;
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-body {
|
.form-body {
|
||||||
|
|
@ -1059,6 +1060,8 @@ const saveAndCreateAnother = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-label input[type="checkbox"] {
|
.check-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
accent-color: var(--candle);
|
accent-color: var(--candle);
|
||||||
}
|
}
|
||||||
|
|
@ -1137,16 +1140,22 @@ const saveAndCreateAnother = async () => {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agenda-remove {
|
.link-btn {
|
||||||
padding: 6px;
|
|
||||||
color: var(--ember);
|
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px dashed transparent;
|
border: none;
|
||||||
|
color: var(--candle);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agenda-remove:hover {
|
.link-btn:hover {
|
||||||
border-color: var(--ember);
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn-danger {
|
||||||
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-agenda-btn {
|
.add-agenda-btn {
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,6 @@
|
||||||
<option value="showcase">Showcase</option>
|
<option value="showcase">Showcase</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-bottom: 0;">
|
|
||||||
<select v-model="statusFilter">
|
|
||||||
<option value="all">All Status</option>
|
|
||||||
<option value="upcoming">Upcoming</option>
|
|
||||||
<option value="ongoing">Ongoing</option>
|
|
||||||
<option value="past">Past</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field" style="margin-bottom: 0;">
|
<div class="field" style="margin-bottom: 0;">
|
||||||
<select v-model="seriesFilter">
|
<select v-model="seriesFilter">
|
||||||
<option value="all">All Events</option>
|
<option value="all">All Events</option>
|
||||||
|
|
@ -44,8 +36,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Events Table -->
|
<!-- Loading / Error -->
|
||||||
<div class="table-wrap">
|
|
||||||
<div v-if="pending" class="loading-state">
|
<div v-if="pending" class="loading-state">
|
||||||
<div class="spinner" />
|
<div class="spinner" />
|
||||||
<span>Loading events...</span>
|
<span>Loading events...</span>
|
||||||
|
|
@ -55,7 +46,15 @@
|
||||||
Error loading events: {{ error }}
|
Error loading events: {{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table v-else-if="filteredEvents.length">
|
<template v-else>
|
||||||
|
<!-- ── Upcoming Events ── -->
|
||||||
|
<div class="section-divider">
|
||||||
|
<span class="section-label">Upcoming Events</span>
|
||||||
|
<span class="event-count">{{ upcomingFiltered.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table v-if="upcomingPaged.length">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-title">Title</th>
|
<th class="col-title">Title</th>
|
||||||
|
|
@ -68,16 +67,11 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="event in filteredEvents" :key="event._id">
|
<tr v-for="event in upcomingPaged" :key="event._id">
|
||||||
<!-- Title -->
|
|
||||||
<td class="col-title">
|
<td class="col-title">
|
||||||
<div class="event-title-cell">
|
<div class="event-title-cell">
|
||||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||||
<img
|
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
|
||||||
:src="event.featureImage.url"
|
|
||||||
:alt="event.title"
|
|
||||||
@error="handleImageError($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="event-name">{{ event.title }}</span>
|
<span class="event-name">{{ event.title }}</span>
|
||||||
|
|
@ -94,27 +88,19 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Type -->
|
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Date -->
|
|
||||||
<td class="col-date">
|
<td class="col-date">
|
||||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<td>
|
<td>
|
||||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||||
{{ getEventStatus(event) }}
|
{{ getEventStatus(event) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Registration -->
|
|
||||||
<td>
|
<td>
|
||||||
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||||
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||||
|
|
@ -122,8 +108,6 @@
|
||||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Tickets -->
|
|
||||||
<td class="col-tickets">
|
<td class="col-tickets">
|
||||||
<template v-if="event.tickets?.enabled">
|
<template v-if="event.tickets?.enabled">
|
||||||
<span class="ticket-on">Ticketing On</span>
|
<span class="ticket-on">Ticketing On</span>
|
||||||
|
|
@ -142,14 +126,8 @@
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<NuxtLink
|
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||||
:to="`/events/${event.slug || String(event._id)}`"
|
|
||||||
class="link-btn"
|
|
||||||
title="View"
|
|
||||||
>View</NuxtLink>
|
|
||||||
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||||
|
|
@ -158,10 +136,114 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">No upcoming events matching your filters</div>
|
||||||
No events found matching your criteria
|
|
||||||
|
<div v-if="upcomingPageCount > 1" class="pagination">
|
||||||
|
<button class="page-btn" :disabled="upcomingPage === 1" @click="upcomingPage--">←</button>
|
||||||
|
<span class="page-info">{{ upcomingPage }} / {{ upcomingPageCount }}</span>
|
||||||
|
<button class="page-btn" :disabled="upcomingPage === upcomingPageCount" @click="upcomingPage++">→</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Past Events ── -->
|
||||||
|
<div class="section-divider">
|
||||||
|
<span class="section-label">Past Events</span>
|
||||||
|
<span class="event-count">{{ pastFiltered.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table v-if="pastPaged.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-title">Title</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Registration</th>
|
||||||
|
<th>Tickets</th>
|
||||||
|
<th class="col-actions-head">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="event in pastPaged" :key="event._id">
|
||||||
|
<td class="col-title">
|
||||||
|
<div class="event-title-cell">
|
||||||
|
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||||
|
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="event-name">{{ event.title }}</span>
|
||||||
|
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
|
||||||
|
<div v-if="event.series?.isSeriesEvent" class="series-tag">
|
||||||
|
<span class="series-pos">{{ event.series.position }}</span>
|
||||||
|
{{ event.series.title }}
|
||||||
|
</div>
|
||||||
|
<div class="event-flags">
|
||||||
|
<span v-if="event.membersOnly" class="flag">Members Only</span>
|
||||||
|
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
|
||||||
|
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-date">
|
||||||
|
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||||
|
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||||
|
{{ getEventStatus(event) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||||
|
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||||
|
<span v-if="event.maxAttendees" class="reg-count">
|
||||||
|
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-tickets">
|
||||||
|
<template v-if="event.tickets?.enabled">
|
||||||
|
<span class="ticket-on">Ticketing On</span>
|
||||||
|
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="event.tickets.member?.available" class="ticket-detail">
|
||||||
|
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
|
||||||
|
</span>
|
||||||
|
<span v-if="event.tickets.public?.available" class="ticket-detail">
|
||||||
|
Public: ${{ event.tickets.public.price || 0 }}
|
||||||
|
<template v-if="event.tickets.public.quantity">
|
||||||
|
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||||
|
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||||
|
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||||
|
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">No past events matching your filters</div>
|
||||||
|
|
||||||
|
<div v-if="pastPageCount > 1" class="pagination">
|
||||||
|
<button class="page-btn" :disabled="pastPage === 1" @click="pastPage--">←</button>
|
||||||
|
<span class="page-info">{{ pastPage }} / {{ pastPageCount }}</span>
|
||||||
|
<button class="page-btn" :disabled="pastPage === pastPageCount" @click="pastPage++">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Confirm Delete Modal -->
|
<!-- Confirm Delete Modal -->
|
||||||
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
|
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|
@ -199,33 +281,12 @@ const {
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const typeFilter = ref('all')
|
const typeFilter = ref('all')
|
||||||
const statusFilter = ref('all')
|
|
||||||
const seriesFilter = ref('all')
|
const seriesFilter = ref('all')
|
||||||
|
|
||||||
const filteredEvents = computed(() => {
|
const upcomingPage = ref(1)
|
||||||
if (!events.value) return []
|
const pastPage = ref(1)
|
||||||
|
const UPCOMING_PAGE_SIZE = 10
|
||||||
return events.value.filter((event) => {
|
const PAST_PAGE_SIZE = 5
|
||||||
const matchesSearch =
|
|
||||||
!searchQuery.value ||
|
|
||||||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
|
||||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
||||||
|
|
||||||
const matchesType =
|
|
||||||
typeFilter.value === 'all' || event.eventType === typeFilter.value
|
|
||||||
|
|
||||||
const eventStatus = getEventStatus(event)
|
|
||||||
const matchesStatus =
|
|
||||||
statusFilter.value === 'all' || eventStatus.toLowerCase() === statusFilter.value
|
|
||||||
|
|
||||||
const matchesSeries =
|
|
||||||
seriesFilter.value === 'all' ||
|
|
||||||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
|
|
||||||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
|
|
||||||
|
|
||||||
return matchesSearch && matchesType && matchesStatus && matchesSeries
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const getEventStatus = (event) => {
|
const getEventStatus = (event) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
@ -237,6 +298,57 @@ const getEventStatus = (event) => {
|
||||||
return 'Past'
|
return 'Past'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyBaseFilters = (list) => {
|
||||||
|
if (!list) return []
|
||||||
|
return list.filter((event) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchQuery.value ||
|
||||||
|
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||||
|
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
|
|
||||||
|
const matchesType =
|
||||||
|
typeFilter.value === 'all' || event.eventType === typeFilter.value
|
||||||
|
|
||||||
|
const matchesSeries =
|
||||||
|
seriesFilter.value === 'all' ||
|
||||||
|
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
|
||||||
|
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
|
||||||
|
|
||||||
|
return matchesSearch && matchesType && matchesSeries
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingFiltered = computed(() => {
|
||||||
|
return applyBaseFilters(events.value)
|
||||||
|
.filter((e) => getEventStatus(e) !== 'Past')
|
||||||
|
.sort((a, b) => new Date(a.startDate) - new Date(b.startDate))
|
||||||
|
})
|
||||||
|
|
||||||
|
const pastFiltered = computed(() => {
|
||||||
|
return applyBaseFilters(events.value)
|
||||||
|
.filter((e) => getEventStatus(e) === 'Past')
|
||||||
|
.sort((a, b) => new Date(b.startDate) - new Date(a.startDate))
|
||||||
|
})
|
||||||
|
|
||||||
|
const upcomingPageCount = computed(() => Math.max(1, Math.ceil(upcomingFiltered.value.length / UPCOMING_PAGE_SIZE)))
|
||||||
|
const pastPageCount = computed(() => Math.max(1, Math.ceil(pastFiltered.value.length / PAST_PAGE_SIZE)))
|
||||||
|
|
||||||
|
const upcomingPaged = computed(() => {
|
||||||
|
const start = (upcomingPage.value - 1) * UPCOMING_PAGE_SIZE
|
||||||
|
return upcomingFiltered.value.slice(start, start + UPCOMING_PAGE_SIZE)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pastPaged = computed(() => {
|
||||||
|
const start = (pastPage.value - 1) * PAST_PAGE_SIZE
|
||||||
|
return pastFiltered.value.slice(start, start + PAST_PAGE_SIZE)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset pagination when filters change
|
||||||
|
watch([searchQuery, typeFilter, seriesFilter], () => {
|
||||||
|
upcomingPage.value = 1
|
||||||
|
pastPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
|
@ -309,10 +421,7 @@ const editEvent = (event) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-events {
|
.admin-events {}
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAGE HEADER ---- */
|
/* ---- PAGE HEADER ---- */
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -350,9 +459,34 @@ const editEvent = (event) => {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- SECTION DIVIDER ---- */
|
||||||
|
.section-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider .section-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 1px 7px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- TABLE ---- */
|
/* ---- TABLE ---- */
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
padding: 0 28px 28px;
|
padding: 12px 28px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
|
|
@ -580,6 +714,41 @@ tbody td {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- PAGINATION ---- */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--candle);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
transition: border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
color: var(--text-faint);
|
||||||
|
border-color: var(--border);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:not(:disabled):hover {
|
||||||
|
border-color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- STATES ---- */
|
/* ---- STATES ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -597,7 +766,7 @@ tbody td {
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 24px;
|
padding: 32px 24px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
@ -699,11 +868,15 @@ tbody td {
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px 20px;
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
padding: 16px 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
padding: 0 12px 20px;
|
padding: 12px 20px 20px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
<div v-else-if="recentMembers.length" class="item-list">
|
<div v-else-if="recentMembers.length" class="item-list">
|
||||||
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
||||||
<div>
|
<div>
|
||||||
<span class="item-name">{{ member.name }}</span>
|
<NuxtLink :to="`/admin/members/${member._id}`" class="item-name">{{ member.name }}</NuxtLink>
|
||||||
<span class="item-sub">{{ member.email }}</span>
|
<span class="item-sub">{{ member.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-meta">
|
<div class="item-meta">
|
||||||
|
|
@ -125,10 +125,7 @@ const formatDateTime = (dateString) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-dash {
|
.admin-dash {}
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAGE HEADER ---- */
|
/* ---- PAGE HEADER ---- */
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -241,6 +238,12 @@ const formatDateTime = (dateString) => {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.item-name:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-sub {
|
.item-sub {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-member-detail">
|
<div class="admin-member-detail">
|
||||||
|
<!-- Page Header -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
<div class="header-nav">
|
||||||
|
<NuxtLink to="/admin/members" class="back-link">← Members</NuxtLink>
|
||||||
|
<NuxtLink v-if="member && member.status === 'active' && member.showInDirectory" :to="`/members/${member._id}`" class="profile-link" target="_blank">
|
||||||
|
View public profile ↗
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<NuxtLink to="/admin/members" class="back-link">← Members</NuxtLink>
|
|
||||||
<h1 v-if="member">{{ member.name }}</h1>
|
<h1 v-if="member">{{ member.name }}</h1>
|
||||||
<h1 v-else-if="pending">Loading…</h1>
|
<h1 v-else-if="pending">Loading…</h1>
|
||||||
<h1 v-else>Member not found</h1>
|
<h1 v-else>Member not found</h1>
|
||||||
<p v-if="member" class="member-email">{{ member.email }}</p>
|
<p v-if="member" class="member-email">{{ member.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="member" class="header-actions">
|
<div v-if="member" class="header-badges">
|
||||||
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
||||||
<span :class="statusClass(member.status)" class="status-badge">{{
|
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
|
||||||
member.status
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,6 +30,9 @@
|
||||||
<div v-else-if="fetchError" class="error-state">Failed to load member.</div>
|
<div v-else-if="fetchError" class="error-state">Failed to load member.</div>
|
||||||
|
|
||||||
<template v-else-if="member">
|
<template v-else-if="member">
|
||||||
|
<div class="detail-body">
|
||||||
|
<!-- LEFT COLUMN: form + metadata -->
|
||||||
|
<div class="detail-left">
|
||||||
<!-- Edit form -->
|
<!-- Edit form -->
|
||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<div class="section-label">Member details</div>
|
<div class="section-label">Member details</div>
|
||||||
|
|
@ -85,6 +92,10 @@
|
||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<div class="section-label">Account info</div>
|
<div class="section-label">Account info</div>
|
||||||
<dl class="meta-list">
|
<dl class="meta-list">
|
||||||
|
<div v-if="member.memberNumber" class="meta-row">
|
||||||
|
<dt>Member number</dt>
|
||||||
|
<dd class="mono">#{{ member.memberNumber }}</dd>
|
||||||
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<dt>Member ID</dt>
|
<dt>Member ID</dt>
|
||||||
<dd class="mono">{{ member._id }}</dd>
|
<dd class="mono">{{ member._id }}</dd>
|
||||||
|
|
@ -122,69 +133,70 @@
|
||||||
<dl class="meta-list">
|
<dl class="meta-list">
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<dt>Event reminders</dt>
|
<dt>Event reminders</dt>
|
||||||
<dd
|
<dd :class="member.notifications?.events !== false ? 'status-ok' : 'status-dim'">
|
||||||
:class="
|
|
||||||
member.notifications?.events !== false
|
|
||||||
? 'status-ok'
|
|
||||||
: 'status-dim'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ member.notifications?.events !== false ? "On" : "Off" }}
|
{{ member.notifications?.events !== false ? "On" : "Off" }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<dt>Community updates</dt>
|
<dt>Community updates</dt>
|
||||||
<dd
|
<dd :class="member.notifications?.updates !== false ? 'status-ok' : 'status-dim'">
|
||||||
:class="
|
|
||||||
member.notifications?.updates !== false
|
|
||||||
? 'status-ok'
|
|
||||||
: 'status-dim'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ member.notifications?.updates !== false ? "On" : "Off" }}
|
{{ member.notifications?.updates !== false ? "On" : "Off" }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<dt>Peer support requests</dt>
|
<dt>Peer support requests</dt>
|
||||||
<dd
|
<dd :class="member.notifications?.peerRequests !== false ? 'status-ok' : 'status-dim'">
|
||||||
:class="
|
|
||||||
member.notifications?.peerRequests !== false
|
|
||||||
? 'status-ok'
|
|
||||||
: 'status-dim'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
|
{{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Full Activity Log -->
|
<!-- RIGHT COLUMN: activity log -->
|
||||||
<section class="detail-section">
|
<div class="detail-right">
|
||||||
<div class="section-label">Full Activity Log</div>
|
<div class="activity-panel">
|
||||||
|
<div class="activity-panel-header">
|
||||||
|
<div class="section-label">Activity log</div>
|
||||||
|
<span class="activity-legend">
|
||||||
|
<span class="al-vis-badge">admin-only</span> = not visible to member
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="activityLoading && !activityEntries.length" class="loading-state">
|
<div v-if="activityLoading && !activityEntries.length" class="activity-loading">
|
||||||
<div class="spinner" />
|
<div class="spinner" />
|
||||||
Loading activity...
|
Loading activity...
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="activityEntries.length" class="activity-log">
|
<div v-else-if="activityEntries.length" class="activity-timeline">
|
||||||
<div v-for="entry in activityEntries" :key="entry._id" class="al-item" :class="{ 'al-admin': entry.visibility === 'admin' }">
|
<div
|
||||||
|
v-for="entry in activityEntries"
|
||||||
|
:key="entry._id"
|
||||||
|
class="al-item"
|
||||||
|
:class="{ 'al-admin': entry.visibility === 'admin' }"
|
||||||
|
>
|
||||||
|
<div class="al-dot" />
|
||||||
|
<div class="al-body">
|
||||||
|
<div class="al-row">
|
||||||
<UIcon :name="getActivity(entry).icon" class="al-icon" />
|
<UIcon :name="getActivity(entry).icon" class="al-icon" />
|
||||||
<span class="al-text">{{ getActivity(entry).text }}</span>
|
<span class="al-text">{{ getActivity(entry).text }}</span>
|
||||||
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
|
|
||||||
<span v-if="entry.visibility === 'admin'" class="al-vis-badge">admin-only</span>
|
<span v-if="entry.visibility === 'admin'" class="al-vis-badge">admin-only</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="activityHasMore" class="al-load-more">
|
<div v-if="activityHasMore" class="al-load-more">
|
||||||
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
|
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
|
||||||
{{ activityLoadingMore ? 'Loading...' : 'Load More' }}
|
{{ activityLoadingMore ? 'Loading...' : 'Load more' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="loading-state">
|
<div v-else class="activity-empty">
|
||||||
No activity recorded.
|
No activity recorded.
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -271,12 +283,12 @@ async function submitEdit() {
|
||||||
member.value = { ...member.value, ...updated, role: form.role };
|
member.value = { ...member.value, ...updated, role: form.role };
|
||||||
pageBreadcrumbTitle.value = form.name;
|
pageBreadcrumbTitle.value = form.name;
|
||||||
}
|
}
|
||||||
toast.add({ title: "Member updated", color: "green" });
|
toast.add({ title: "Member updated", color: "success" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Failed to update member",
|
title: "Failed to update member",
|
||||||
description: err.data?.statusMessage || err.message,
|
description: err.data?.statusMessage || err.message,
|
||||||
color: "red",
|
color: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
|
|
@ -344,13 +356,42 @@ onMounted(loadActivity)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-member-detail {
|
.admin-member-detail {}
|
||||||
max-width: 640px;
|
|
||||||
padding: 32px 40px 60px;
|
/* ---- PAGE HEADER ---- */
|
||||||
|
.page-header {
|
||||||
|
padding: 28px 28px 20px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.header-nav {
|
||||||
margin-bottom: 32px;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-link {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-row {
|
.header-row {
|
||||||
|
|
@ -360,26 +401,13 @@ onMounted(loadActivity)
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
text-decoration: none;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header h1 {
|
.page-header h1 {
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-email {
|
.member-email {
|
||||||
|
|
@ -388,29 +416,44 @@ onMounted(loadActivity)
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 6px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TWO-COLUMN BODY ---- */
|
||||||
|
.detail-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-left {
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-section {
|
.detail-section {
|
||||||
margin-bottom: 40px;
|
padding: 24px 28px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,6 +461,8 @@ onMounted(loadActivity)
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-list {
|
.meta-list {
|
||||||
|
|
@ -430,8 +475,8 @@ onMounted(loadActivity)
|
||||||
|
|
||||||
.meta-row {
|
.meta-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
padding: 10px 14px;
|
padding: 9px 14px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,6 +497,7 @@ onMounted(loadActivity)
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
|
|
@ -459,8 +505,9 @@ onMounted(loadActivity)
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- STATUS ---- */
|
||||||
.status-ok {
|
.status-ok {
|
||||||
color: var(--c-founder);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dim {
|
.status-dim {
|
||||||
|
|
@ -471,6 +518,7 @@ onMounted(loadActivity)
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- STATES ---- */
|
||||||
.loading-state,
|
.loading-state,
|
||||||
.error-state {
|
.error-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -478,46 +526,122 @@ onMounted(loadActivity)
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 40px 0;
|
padding: 40px 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border: 2px solid var(--border);
|
border: 2px dashed var(--candle);
|
||||||
border-top-color: var(--candle);
|
border-top-color: transparent;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to { transform: rotate(360deg); }
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- ACTIVITY LOG ---- */
|
/* ---- ACTIVITY PANEL ---- */
|
||||||
.activity-log {
|
.detail-right {
|
||||||
margin-top: 12px;
|
position: relative;
|
||||||
border: 1px dashed var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-item {
|
.activity-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
padding: 8px 14px;
|
padding: 24px 28px 16px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-legend {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
color: var(--text-faint);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-item:last-child {
|
.activity-empty {
|
||||||
border-bottom: none;
|
padding: 32px 28px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-admin {
|
/* Timeline */
|
||||||
opacity: 0.7;
|
.activity-timeline {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.al-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 1fr;
|
||||||
|
gap: 0 10px;
|
||||||
|
padding: 0 28px 0 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.al-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 27px;
|
||||||
|
top: 18px;
|
||||||
|
bottom: -16px;
|
||||||
|
width: 1px;
|
||||||
|
border-left: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.al-item:last-child::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.al-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.al-admin .al-dot {
|
||||||
|
border-color: var(--candle-faint);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.al-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.al-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-icon {
|
.al-icon {
|
||||||
|
|
@ -525,41 +649,75 @@ onMounted(loadActivity)
|
||||||
height: 14px;
|
height: 14px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-text {
|
.al-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--text-dim);
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-time {
|
.al-time {
|
||||||
|
display: block;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
flex-shrink: 0;
|
margin-top: 3px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-vis-badge {
|
.al-vis-badge {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-faint);
|
color: var(--candle);
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--candle-faint);
|
||||||
padding: 1px 4px;
|
padding: 1px 5px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-load-more {
|
.al-load-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 12px;
|
padding: 8px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- RESPONSIVE ---- */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.detail-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-left {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-panel {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-member-detail {
|
.page-header {
|
||||||
padding: 20px 16px 40px;
|
padding: 24px 20px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-row {
|
.detail-section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-panel-header {
|
||||||
|
padding: 16px 20px 12px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.al-item {
|
||||||
|
padding: 0 20px 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-row {
|
.meta-row {
|
||||||
|
|
|
||||||
|
|
@ -55,18 +55,15 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-check">
|
<th class="col-check">
|
||||||
<UCheckbox
|
<label class="custom-check" aria-label="Select all members">
|
||||||
label="Select all members"
|
<input
|
||||||
:ui="{ label: 'sr-only' }"
|
type="checkbox"
|
||||||
:model-value="
|
:checked="allVisibleSelected"
|
||||||
allVisibleSelected
|
:indeterminate="!allVisibleSelected && someVisibleSelected"
|
||||||
? true
|
@change="toggleSelectAll"
|
||||||
: someVisibleSelected
|
|
||||||
? 'indeterminate'
|
|
||||||
: false
|
|
||||||
"
|
|
||||||
@update:model-value="toggleSelectAll"
|
|
||||||
/>
|
/>
|
||||||
|
<span class="check-mark" />
|
||||||
|
</label>
|
||||||
</th>
|
</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
|
@ -79,16 +76,26 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="member in filteredMembers" :key="member._id">
|
<tr
|
||||||
<td class="col-check">
|
v-for="member in filteredMembers"
|
||||||
<UCheckbox
|
:key="member._id"
|
||||||
:label="`Select ${member.name}`"
|
class="selectable-row"
|
||||||
:ui="{ label: 'sr-only' }"
|
:class="{ 'row-selected': selectedMemberIds.includes(member._id) }"
|
||||||
:model-value="selectedMemberIds.includes(member._id)"
|
@click="toggleSelect(member._id)"
|
||||||
@update:model-value="toggleSelect(member._id)"
|
>
|
||||||
|
<td class="col-check" @click.stop>
|
||||||
|
<label class="custom-check" :aria-label="`Select ${member.name}`">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedMemberIds.includes(member._id)"
|
||||||
|
@change="toggleSelect(member._id)"
|
||||||
/>
|
/>
|
||||||
|
<span class="check-mark" />
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="col-name">
|
||||||
|
<NuxtLink :to="`/admin/members/${member._id}`" class="member-name-link" @click.stop>{{ member.name }}</NuxtLink>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-name">{{ member.name }}</td>
|
|
||||||
<td class="col-email">{{ member.email }}</td>
|
<td class="col-email">{{ member.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="member.circle">{{
|
<span class="badge" :class="member.circle">{{
|
||||||
|
|
@ -112,13 +119,13 @@
|
||||||
{{ formatDate(member.createdAt) }}
|
{{ formatDate(member.createdAt) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn"
|
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop
|
||||||
>View</NuxtLink
|
>View</NuxtLink
|
||||||
>
|
>
|
||||||
<button @click="sendSlackInvite(member)" class="link-btn">
|
<button @click.stop="sendSlackInvite(member)" class="link-btn">
|
||||||
Slack
|
Slack
|
||||||
</button>
|
</button>
|
||||||
<button @click="editMember(member)" class="link-btn">Edit</button>
|
<button @click.stop="editMember(member)" class="link-btn">Edit</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -602,13 +609,13 @@ const createMember = async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await refresh();
|
await refresh();
|
||||||
toast.add({ title: "Member created", color: "green" });
|
toast.add({ title: "Member created", color: "success" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create member:", err);
|
console.error("Failed to create member:", err);
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Failed to create member",
|
title: "Failed to create member",
|
||||||
description: err.data?.statusMessage || err.message,
|
description: err.data?.statusMessage || err.message,
|
||||||
color: "red",
|
color: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
|
|
@ -718,14 +725,14 @@ const submitImport = async () => {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: `Imported ${result.created} member${result.created !== 1 ? "s" : ""}`,
|
title: `Imported ${result.created} member${result.created !== 1 ? "s" : ""}`,
|
||||||
description: result.failed ? `${result.failed} failed` : undefined,
|
description: result.failed ? `${result.failed} failed` : undefined,
|
||||||
color: result.failed ? "orange" : "green",
|
color: result.failed ? "warning" : "success",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Import failed:", err);
|
console.error("Import failed:", err);
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Import failed",
|
title: "Import failed",
|
||||||
description: err.data?.statusMessage || err.message,
|
description: err.data?.statusMessage || err.message,
|
||||||
color: "red",
|
color: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
importing.value = false;
|
importing.value = false;
|
||||||
|
|
@ -757,14 +764,14 @@ const submitInvites = async () => {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: `Sent ${result.sent} invite${result.sent !== 1 ? "s" : ""}`,
|
title: `Sent ${result.sent} invite${result.sent !== 1 ? "s" : ""}`,
|
||||||
description: result.failed ? `${result.failed} failed` : undefined,
|
description: result.failed ? `${result.failed} failed` : undefined,
|
||||||
color: result.failed ? "orange" : "green",
|
color: result.failed ? "warning" : "success",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to send invites:", err);
|
console.error("Failed to send invites:", err);
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Failed to send invites",
|
title: "Failed to send invites",
|
||||||
description: err.data?.statusMessage || err.message,
|
description: err.data?.statusMessage || err.message,
|
||||||
color: "red",
|
color: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
sendingInvites.value = false;
|
sendingInvites.value = false;
|
||||||
|
|
@ -815,12 +822,12 @@ const submitEditMember = async () => {
|
||||||
});
|
});
|
||||||
showEditModal.value = false;
|
showEditModal.value = false;
|
||||||
await refresh();
|
await refresh();
|
||||||
toast.add({ title: "Member updated", color: "green" });
|
toast.add({ title: "Member updated", color: "success" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Failed to update member",
|
title: "Failed to update member",
|
||||||
description: err.data?.statusMessage || err.message,
|
description: err.data?.statusMessage || err.message,
|
||||||
color: "red",
|
color: "error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
|
|
@ -829,10 +836,7 @@ const submitEditMember = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-members {
|
.admin-members {}
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAGE HEADER ---- */
|
/* ---- PAGE HEADER ---- */
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -914,16 +918,94 @@ tbody td {
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-check {
|
.col-check {
|
||||||
width: 32px;
|
width: 40px;
|
||||||
padding-left: 0;
|
padding-left: 12px;
|
||||||
padding-right: 4px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
.col-name {
|
.col-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-name-link {
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name-link:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.col-email {
|
.col-email {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -1184,11 +1266,11 @@ tbody td {
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px 20px;
|
padding: 16px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
padding: 0 12px 20px;
|
padding: 0 20px 20px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,103 +65,66 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-check">
|
<th class="col-check">
|
||||||
<UCheckbox
|
<label class="custom-check" aria-label="Select all">
|
||||||
label="Select all"
|
<input
|
||||||
:ui="{ label: 'sr-only' }"
|
type="checkbox"
|
||||||
:model-value="
|
:checked="allVisibleSelected"
|
||||||
allVisibleSelected
|
:indeterminate="!allVisibleSelected && someVisibleSelected"
|
||||||
? true
|
@change="toggleSelectAll"
|
||||||
: someVisibleSelected
|
|
||||||
? 'indeterminate'
|
|
||||||
: false
|
|
||||||
"
|
|
||||||
@update:model-value="toggleSelectAll"
|
|
||||||
/>
|
/>
|
||||||
|
<span class="check-mark" />
|
||||||
|
</label>
|
||||||
</th>
|
</th>
|
||||||
<th>Name</th>
|
<th class="sortable" @click="toggleSort('name')">Name <span v-if="sortKey === 'name'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||||
<th>Email</th>
|
<th class="sortable" @click="toggleSort('email')">Email <span v-if="sortKey === 'email'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||||
<th>City</th>
|
<th class="sortable" @click="toggleSort('city')">City <span v-if="sortKey === 'city'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||||
<th>Role</th>
|
<th class="sortable" @click="toggleSort('role')">Role <span v-if="sortKey === 'role'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||||
<th>Status</th>
|
<th class="sortable" @click="toggleSort('status')">Status <span v-if="sortKey === 'status'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||||
<th class="col-date">Registered</th>
|
<th class="sortable col-date" @click="toggleSort('createdAt')">Registered <span v-if="sortKey === 'createdAt'" class="sort-arrow">{{ sortDir === 'asc' ? '↑' : '↓' }}</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="pr in filtered" :key="pr._id">
|
|
||||||
<tr
|
<tr
|
||||||
:class="{ 'row-expanded': expandedId === pr._id }"
|
v-for="pr in filtered"
|
||||||
@click="toggleExpand(pr._id)"
|
:key="pr._id"
|
||||||
style="cursor: pointer"
|
class="selectable-row"
|
||||||
|
:class="{ 'row-selected': selectedIds.includes(pr._id) }"
|
||||||
|
@click="toggleSelect(pr._id)"
|
||||||
>
|
>
|
||||||
<td class="col-check" @click.stop>
|
<td class="col-check" @click.stop>
|
||||||
<UCheckbox
|
<label class="custom-check" :aria-label="`Select ${pr.name || pr.email}`">
|
||||||
:label="`Select ${pr.name || pr.email}`"
|
<input
|
||||||
:ui="{ label: 'sr-only' }"
|
type="checkbox"
|
||||||
:model-value="selectedIds.includes(pr._id)"
|
:checked="selectedIds.includes(pr._id)"
|
||||||
@update:model-value="toggleSelect(pr._id)"
|
@change="toggleSelect(pr._id)"
|
||||||
/>
|
/>
|
||||||
|
<span class="check-mark" />
|
||||||
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-name">{{ pr.name || "—" }}</td>
|
<td class="col-name">{{ pr.name || "—" }}</td>
|
||||||
<td class="col-email">{{ pr.email }}</td>
|
<td class="col-email">{{ pr.email }}</td>
|
||||||
<td>{{ pr.city || "—" }}</td>
|
<td>{{ pr.city || "—" }}</td>
|
||||||
<td>{{ pr.role || "—" }}</td>
|
<td>{{ pr.role || "—" }}</td>
|
||||||
<td>
|
<td @click.stop>
|
||||||
<span class="status-badge" :class="`status-${pr.status}`">
|
<select
|
||||||
{{ pr.status }}
|
class="inline-status"
|
||||||
</span>
|
:class="`status-${pr.status}`"
|
||||||
|
:value="pr.status"
|
||||||
|
:disabled="savingId === pr._id"
|
||||||
|
aria-label="Change status"
|
||||||
|
@change="updateStatus(pr._id, $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="selected">Selected</option>
|
||||||
|
<option value="invited">Invited</option>
|
||||||
|
<option value="accepted">Accepted</option>
|
||||||
|
<option value="expired">Expired</option>
|
||||||
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-mono col-date">
|
<td class="col-mono col-date">
|
||||||
{{ formatDate(pr.createdAt) }}
|
{{ formatDate(pr.createdAt) }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Expanded detail row -->
|
|
||||||
<tr v-if="expandedId === pr._id" class="detail-row">
|
|
||||||
<td colspan="7">
|
|
||||||
<div class="detail-panel">
|
|
||||||
<div class="detail-fields">
|
|
||||||
<div class="field">
|
|
||||||
<label>Admin Notes</label>
|
|
||||||
<textarea
|
|
||||||
v-model="editNotes"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Add notes about this pre-registrant..."
|
|
||||||
@click.stop
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="detail-actions">
|
|
||||||
<select
|
|
||||||
v-model="editStatus"
|
|
||||||
aria-label="Change status"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="selected">Selected</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
@click.stop="saveDetail(pr._id)"
|
|
||||||
:disabled="savingDetail"
|
|
||||||
>
|
|
||||||
{{ savingDetail ? "Saving..." : "Save" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="pr.inviteEmailSentAt"
|
|
||||||
class="detail-meta"
|
|
||||||
>
|
|
||||||
Invite sent:
|
|
||||||
{{ new Date(pr.inviteEmailSentAt).toLocaleString() }}
|
|
||||||
</div>
|
|
||||||
<div v-if="pr.acceptedAt" class="detail-meta">
|
|
||||||
Accepted:
|
|
||||||
{{ new Date(pr.acceptedAt).toLocaleString() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -267,10 +230,9 @@ const { data: stats, refresh: refreshStats } = await useFetch(
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const statusFilter = ref("");
|
const statusFilter = ref("");
|
||||||
const selectedIds = ref([]);
|
const selectedIds = ref([]);
|
||||||
const expandedId = ref(null);
|
const savingId = ref(null);
|
||||||
const editNotes = ref("");
|
const sortKey = ref("");
|
||||||
const editStatus = ref("pending");
|
const sortDir = ref("asc");
|
||||||
const savingDetail = ref(false);
|
|
||||||
|
|
||||||
// Invite
|
// Invite
|
||||||
const showInviteModal = ref(false);
|
const showInviteModal = ref(false);
|
||||||
|
|
@ -291,10 +253,19 @@ See you inside.`;
|
||||||
|
|
||||||
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
|
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
|
||||||
|
|
||||||
|
const toggleSort = (key) => {
|
||||||
|
if (sortKey.value === key) {
|
||||||
|
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sortKey.value = key;
|
||||||
|
sortDir.value = "asc";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
if (!preRegistrants.value) return [];
|
if (!preRegistrants.value) return [];
|
||||||
|
|
||||||
return preRegistrants.value.filter((pr) => {
|
const result = preRegistrants.value.filter((pr) => {
|
||||||
const q = searchQuery.value.toLowerCase();
|
const q = searchQuery.value.toLowerCase();
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!q ||
|
!q ||
|
||||||
|
|
@ -307,6 +278,18 @@ const filtered = computed(() => {
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
return matchesSearch && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Selection helpers
|
// Selection helpers
|
||||||
|
|
@ -319,12 +302,12 @@ const someVisibleSelected = computed(() => {
|
||||||
return filtered.value.some((pr) => selectedIds.value.includes(pr._id));
|
return filtered.value.some((pr) => selectedIds.value.includes(pr._id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// IDs of selected pre-registrants that can actually be invited (pending or selected status)
|
// IDs of selected pre-registrants that can actually be invited (pending, selected, or invited for resend)
|
||||||
const invitableIds = computed(() => {
|
const invitableIds = computed(() => {
|
||||||
if (!preRegistrants.value) return [];
|
if (!preRegistrants.value) return [];
|
||||||
return selectedIds.value.filter((id) => {
|
return selectedIds.value.filter((id) => {
|
||||||
const pr = preRegistrants.value.find((p) => p._id === id);
|
const pr = preRegistrants.value.find((p) => p._id === id);
|
||||||
return pr && (pr.status === "pending" || pr.status === "selected");
|
return pr && (pr.status === "pending" || pr.status === "selected" || pr.status === "invited");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -350,28 +333,16 @@ const toggleSelect = (id) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expand / collapse detail rows
|
const updateStatus = async (id, newStatus) => {
|
||||||
const toggleExpand = (id) => {
|
savingId.value = id;
|
||||||
if (expandedId.value === id) {
|
|
||||||
expandedId.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expandedId.value = id;
|
|
||||||
const pr = preRegistrants.value?.find((p) => p._id === id);
|
|
||||||
editNotes.value = pr?.adminNotes || "";
|
|
||||||
editStatus.value = pr?.status === "selected" ? "selected" : "pending";
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveDetail = async (id) => {
|
|
||||||
savingDetail.value = true;
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/admin/pre-registrants/${id}`, {
|
await $fetch(`/api/admin/pre-registrants/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: { status: editStatus.value, adminNotes: editNotes.value },
|
body: { status: newStatus },
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
await refreshStats();
|
await refreshStats();
|
||||||
toast.add({ title: "Updated", color: "green" });
|
toast.add({ title: "Status updated", color: "green" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Failed to update",
|
title: "Failed to update",
|
||||||
|
|
@ -379,7 +350,7 @@ const saveDetail = async (id) => {
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
savingDetail.value = false;
|
savingId.value = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -459,10 +430,7 @@ const formatDate = (dateString) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-prereg {
|
.admin-prereg {}
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAGE HEADER ---- */
|
/* ---- PAGE HEADER ---- */
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -525,6 +493,21 @@ thead th {
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
border-bottom: 1px dashed var(--border);
|
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 {
|
tbody tr {
|
||||||
|
|
@ -543,11 +526,78 @@ tbody td {
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-check {
|
.col-check {
|
||||||
width: 32px;
|
width: 40px;
|
||||||
padding-left: 0;
|
padding-left: 12px;
|
||||||
padding-right: 4px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
.col-name {
|
.col-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -600,42 +650,21 @@ tbody td {
|
||||||
border-color: var(--ember);
|
border-color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- EXPANDED DETAIL ROW ---- */
|
/* ---- INLINE STATUS SELECT ---- */
|
||||||
.row-expanded {
|
.inline-status {
|
||||||
background: var(--surface);
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row td {
|
.inline-status:disabled {
|
||||||
padding: 0 10px 16px;
|
opacity: 0.5;
|
||||||
border-bottom: 1px dashed var(--border);
|
cursor: wait;
|
||||||
}
|
|
||||||
|
|
||||||
.detail-panel {
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-fields {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-fields .field {
|
|
||||||
flex: 1;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-end;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-meta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- STATUS INDICATORS ---- */
|
/* ---- STATUS INDICATORS ---- */
|
||||||
|
|
@ -822,9 +851,5 @@ tbody td {
|
||||||
table {
|
table {
|
||||||
min-width: 600px;
|
min-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-fields {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
<div class="series-title-row">
|
<div class="series-title-row">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
|
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
|
||||||
<h3>{{ series.title }}</h3>
|
<h2>{{ series.title }}</h2>
|
||||||
<p class="series-desc">{{ series.description }}</p>
|
<p class="series-desc">{{ series.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="series-meta">
|
<div class="series-meta">
|
||||||
|
|
@ -171,15 +171,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="section-label">Series Management Tools</div>
|
<div class="section-label">Series Management Tools</div>
|
||||||
<button @click="reorderAllSeries" class="bulk-action">
|
<button @click="reorderAllSeries" class="btn bulk-action">
|
||||||
<strong>Auto-Reorder Series</strong>
|
<strong>Auto-Reorder Series</strong>
|
||||||
<span>Fix position numbers based on event dates</span>
|
<span>Fix position numbers based on event dates</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="validateAllSeries" class="bulk-action">
|
<button @click="validateAllSeries" class="btn bulk-action">
|
||||||
<strong>Validate Series Data</strong>
|
<strong>Validate Series Data</strong>
|
||||||
<span>Check for consistency issues</span>
|
<span>Check for consistency issues</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="exportSeriesData" class="bulk-action">
|
<button @click="exportSeriesData" class="btn bulk-action">
|
||||||
<strong>Export Series Data</strong>
|
<strong>Export Series Data</strong>
|
||||||
<span>Download series information as JSON</span>
|
<span>Download series information as JSON</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -714,10 +714,7 @@ const exportSeriesData = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-series {
|
.admin-series {}
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PAGE HEADER ---- */
|
/* ---- PAGE HEADER ---- */
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -813,7 +810,7 @@ const exportSeriesData = () => {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.series-header h3 {
|
.series-header h2 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: 'Brygada 1918', serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -1217,7 +1214,7 @@ const exportSeriesData = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.series-list {
|
.series-list {
|
||||||
padding: 16px 12px;
|
padding: 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.series-header,
|
.series-header,
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,6 @@ const createAndAddEvent = async () => {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.create-form {
|
.create-form {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|
@ -271,13 +270,14 @@ const createAndAddEvent = async () => {
|
||||||
.page-header p { font-size: 12px; color: var(--text-dim); }
|
.page-header p { font-size: 12px; color: var(--text-dim); }
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--candle);
|
color: var(--text-faint);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.back-link:hover { text-decoration: underline; }
|
.back-link:hover { color: var(--candle); text-decoration: none; }
|
||||||
|
|
||||||
.form-body { padding: 24px 28px; }
|
.form-body { padding: 24px 28px; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Profile Content -->
|
<!-- Profile Content -->
|
||||||
<div v-else class="profile-content">
|
<template v-else>
|
||||||
<!-- Header Area -->
|
|
||||||
<div class="profile-header">
|
<!-- HERO: full-bleed, outside SidebarLayout -->
|
||||||
|
<div class="profile-hero" :class="{ 'profile-hero--with-links': hasSocialLinks }">
|
||||||
|
|
||||||
|
<!-- Left: Avatar + Identity -->
|
||||||
|
<div class="profile-hero-left">
|
||||||
<div class="profile-avatar">
|
<div class="profile-avatar">
|
||||||
<img
|
<img
|
||||||
v-if="member.avatar"
|
v-if="member.avatar"
|
||||||
|
|
@ -23,64 +27,109 @@
|
||||||
:alt="member.name"
|
:alt="member.name"
|
||||||
class="profile-avatar-img"
|
class="profile-avatar-img"
|
||||||
/>
|
/>
|
||||||
<span v-else class="profile-initials">{{
|
<span v-else class="profile-initials">{{ getInitials(member.name) }}</span>
|
||||||
getInitials(member.name)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-identity">
|
<div class="profile-identity">
|
||||||
<h1 class="profile-name">
|
<h1 class="profile-name">
|
||||||
{{ member.name }}
|
{{ member.name }}<span v-if="member.memberNumber" class="profile-member-number">#{{ member.memberNumber }}</span>
|
||||||
<span v-if="member.pronouns" class="profile-pronouns">{{
|
|
||||||
member.pronouns
|
|
||||||
}}</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
<div v-if="member.pronouns" class="profile-pronouns-row">
|
||||||
|
<span class="profile-pronouns">{{ member.pronouns }}</span>
|
||||||
|
</div>
|
||||||
<div class="profile-meta">
|
<div class="profile-meta">
|
||||||
<span v-if="member.circle" class="badge" :class="member.circle">{{
|
<span v-if="member.circle" class="badge" :class="member.circle">
|
||||||
circleLabels[member.circle]
|
{{ circleLabels[member.circle] }}
|
||||||
}}</span>
|
</span>
|
||||||
<template v-if="member.studio">
|
<template v-if="member.studio">
|
||||||
<span class="meta-sep">·</span>
|
<span class="meta-sep">·</span>
|
||||||
<span class="profile-studio">{{ member.studio }}</span>
|
<span class="profile-studio">{{ member.studio }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="member.location || member.timeZone">
|
||||||
|
<span class="meta-sep">·</span>
|
||||||
|
<span class="profile-location">
|
||||||
|
{{ [member.location, member.timeZone].filter(Boolean).join(' · ') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bio Section -->
|
<!-- Right: Social Links (only when present) -->
|
||||||
<div v-if="member.bio" class="profile-section">
|
<div v-if="hasSocialLinks" class="profile-hero-right">
|
||||||
|
<div class="section-label">Links</div>
|
||||||
|
<div class="social-links">
|
||||||
|
<a
|
||||||
|
v-if="member.socialLinks.website"
|
||||||
|
:href="member.socialLinks.website"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="social-link"
|
||||||
|
>Website</a>
|
||||||
|
<a
|
||||||
|
v-if="member.socialLinks.itch"
|
||||||
|
:href="member.socialLinks.itch"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="social-link"
|
||||||
|
>itch.io</a>
|
||||||
|
<a
|
||||||
|
v-if="member.socialLinks.mastodon"
|
||||||
|
:href="member.socialLinks.mastodon"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="social-link"
|
||||||
|
>Mastodon</a>
|
||||||
|
<a
|
||||||
|
v-if="member.socialLinks.bluesky"
|
||||||
|
:href="member.socialLinks.bluesky"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="social-link"
|
||||||
|
>Bluesky</a>
|
||||||
|
<a
|
||||||
|
v-if="member.socialLinks.linkedin"
|
||||||
|
:href="member.socialLinks.linkedin"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="social-link"
|
||||||
|
>LinkedIn</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- END HERO -->
|
||||||
|
|
||||||
|
<!-- SidebarLayout wraps all remaining sections -->
|
||||||
|
<SidebarLayout>
|
||||||
|
|
||||||
|
<!-- Bio: parch (inverted) block -->
|
||||||
|
<div v-if="member.bio" class="profile-section profile-section--parch">
|
||||||
<div class="section-label">About</div>
|
<div class="section-label">About</div>
|
||||||
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
|
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location & Timezone -->
|
<!-- Two-column: Craft Tags + Community Connections -->
|
||||||
<div v-if="member.location || member.timeZone" class="profile-section">
|
<div
|
||||||
<div class="section-label">Location</div>
|
v-if="craftTagsDisplay.length > 0 || member.offering?.text || connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
|
||||||
<p class="profile-detail">
|
class="profile-two-col"
|
||||||
{{ [member.location, member.timeZone].filter(Boolean).join(" · ") }}
|
>
|
||||||
</p>
|
<!-- Left: What I Do -->
|
||||||
</div>
|
<div class="profile-section">
|
||||||
|
|
||||||
<!-- What I Do (craft tags, falling back to offering) -->
|
|
||||||
<div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section">
|
|
||||||
<div class="section-label">What I Do</div>
|
<div class="section-label">What I Do</div>
|
||||||
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
|
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
|
||||||
<span
|
<span
|
||||||
v-for="tag in craftTagsDisplay"
|
v-for="tag in craftTagsDisplay"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="tag-pill"
|
class="tag-pill"
|
||||||
>{{ tagLabel('craft', tag) }}</span
|
>{{ tagLabel('craft', tag) }}</span>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="member.offering?.text" class="profile-detail offering-text">
|
<p v-if="member.offering?.text" class="profile-detail offering-text">
|
||||||
{{ member.offering.text }}
|
{{ member.offering.text }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Community Connections (cooperative topics with states, falling back to lookingFor) -->
|
<!-- Right: Community Connections -->
|
||||||
<div
|
<div class="profile-section">
|
||||||
v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
|
|
||||||
class="profile-section"
|
|
||||||
>
|
|
||||||
<div class="section-label">Community Connections</div>
|
<div class="section-label">Community Connections</div>
|
||||||
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
|
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
|
||||||
<span
|
<span
|
||||||
|
|
@ -99,123 +148,74 @@
|
||||||
{{ member.lookingFor.text }}
|
{{ member.lookingFor.text }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Social Links -->
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
member.socialLinks && Object.values(member.socialLinks).some(Boolean)
|
|
||||||
"
|
|
||||||
class="profile-section"
|
|
||||||
>
|
|
||||||
<div class="section-label">Links</div>
|
|
||||||
<div class="social-links">
|
|
||||||
<a
|
|
||||||
v-if="member.socialLinks.website"
|
|
||||||
:href="member.socialLinks.website"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="social-link"
|
|
||||||
>Website</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
v-if="member.socialLinks.itch"
|
|
||||||
:href="member.socialLinks.itch"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="social-link"
|
|
||||||
>itch.io</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
v-if="member.socialLinks.mastodon"
|
|
||||||
:href="member.socialLinks.mastodon"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="social-link"
|
|
||||||
>Mastodon</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
v-if="member.socialLinks.bluesky"
|
|
||||||
:href="member.socialLinks.bluesky"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="social-link"
|
|
||||||
>Bluesky</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
v-if="member.socialLinks.linkedin"
|
|
||||||
:href="member.socialLinks.linkedin"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="social-link"
|
|
||||||
>LinkedIn</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peer Support Section (reads from communityConnections, falls back to peerSupport) -->
|
<!-- Peer Support -->
|
||||||
<div v-if="showPeerSupport" class="profile-section">
|
<div v-if="showPeerSupport" class="profile-section">
|
||||||
<div class="section-label">Peer Support</div>
|
<div class="section-label">Peer Support</div>
|
||||||
|
<div class="dashed-box no-hover">
|
||||||
<div v-if="member.peerSupport?.skillTopics?.length" class="peer-group">
|
<div v-if="member.peerSupport?.skillTopics?.length" class="peer-group">
|
||||||
<span class="peer-label">Skills:</span>
|
<span class="peer-label">Skills</span>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
<span
|
<span
|
||||||
v-for="topic in member.peerSupport.skillTopics"
|
v-for="topic in member.peerSupport.skillTopics"
|
||||||
:key="topic"
|
:key="topic"
|
||||||
class="tag-pill"
|
class="tag-pill"
|
||||||
>{{ topic }}</span
|
>{{ topic }}</span>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
|
<div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
|
||||||
<span class="peer-label">Topics:</span>
|
<span class="peer-label">Topics</span>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
<span
|
<span
|
||||||
v-for="topic in member.peerSupport.supportTopics"
|
v-for="topic in member.peerSupport.supportTopics"
|
||||||
:key="topic"
|
:key="topic"
|
||||||
class="tag-pill"
|
class="tag-pill"
|
||||||
>{{ topic }}</span
|
>{{ topic }}</span>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="peerAvailability" class="profile-detail">
|
<p v-if="peerAvailability" class="profile-detail peer-availability">
|
||||||
{{ peerAvailability }}
|
{{ peerAvailability }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
<div v-if="activityEntries.length" class="profile-section">
|
<div v-if="activityEntries.length" class="profile-section">
|
||||||
<div class="section-label">Recent Activity</div>
|
<div class="section-label">Recent Activity</div>
|
||||||
<div class="activity-list">
|
<div class="activity-timeline">
|
||||||
<div v-for="entry in activityEntries" :key="entry._id" class="activity-item">
|
<div v-for="entry in activityEntries" :key="entry._id" class="activity-entry">
|
||||||
<UIcon :name="getActivity(entry).icon" class="activity-icon" />
|
<UIcon :name="getActivity(entry).icon" class="activity-icon" />
|
||||||
|
<div class="activity-body">
|
||||||
<span class="activity-text">{{ getActivity(entry).text }}</span>
|
<span class="activity-text">{{ getActivity(entry).text }}</span>
|
||||||
<span class="activity-time">{{ formatRelativeDate(entry.timestamp) }}</span>
|
<span class="activity-time">{{ formatRelativeDate(entry.timestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Auth Notice -->
|
<!-- Auth Notice -->
|
||||||
<div v-if="!isAuthenticated" class="auth-notice">
|
<div v-if="!isAuthenticated" class="profile-section">
|
||||||
|
<div class="auth-notice">
|
||||||
<p>Sign in to see full profile details</p>
|
<p>Sign in to see full profile details</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn"
|
class="btn"
|
||||||
@click="
|
@click="openLoginModal({ title: 'Sign in to see more', description: 'Log in to view full member profiles' })"
|
||||||
openLoginModal({
|
|
||||||
title: 'Sign in to see more',
|
|
||||||
description: 'Log in to view full member profiles',
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
Log In
|
Log In
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Back Link -->
|
<!-- Back Link -->
|
||||||
<div class="profile-back">
|
<div class="profile-back">
|
||||||
<NuxtLink to="/members" class="back-link">← Back to Members</NuxtLink>
|
<NuxtLink to="/members" class="back-link">← Back to Members</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
</SidebarLayout>
|
||||||
|
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -333,6 +333,11 @@ const peerAvailability = computed(() => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whether the member has any social links (for hero layout)
|
||||||
|
const hasSocialLinks = computed(() =>
|
||||||
|
member.value?.socialLinks && Object.values(member.value.socialLinks).some(Boolean)
|
||||||
|
)
|
||||||
|
|
||||||
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
|
||||||
watch(
|
watch(
|
||||||
member,
|
member,
|
||||||
|
|
@ -356,52 +361,77 @@ useHead({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* ====================================================
|
||||||
|
PROFILE PAGE
|
||||||
|
Full-bleed layout: no max-width, no centering.
|
||||||
|
Flex chain enables SidebarLayout's flex: 1 to work.
|
||||||
|
==================================================== */
|
||||||
|
|
||||||
.profile-page {
|
.profile-page {
|
||||||
max-width: 720px;
|
flex: 1;
|
||||||
margin: 0 auto;
|
display: flex;
|
||||||
padding: 0 24px 60px;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- LOADING ---- */
|
/* ---- LOADING STATE ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
padding: 80px 24px;
|
padding: 80px 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- ERROR / 404 ---- */
|
/* ---- ERROR / 404 STATE ---- */
|
||||||
.error-state {
|
.error-state {
|
||||||
padding: 80px 24px;
|
padding: 80px 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-title {
|
.error-title {
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-sub {
|
.error-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- HEADER ---- */
|
/* ====================================================
|
||||||
.profile-header {
|
HERO — full-bleed, two-column when social links exist
|
||||||
display: flex;
|
==================================================== */
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
.profile-hero {
|
||||||
padding: 28px 0 24px;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-hero--with-links {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero-left {
|
||||||
|
padding: 32px 32px 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero--with-links .profile-hero-left {
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero-right {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
.profile-avatar {
|
.profile-avatar {
|
||||||
width: 48px;
|
width: 96px;
|
||||||
height: 48px;
|
height: 96px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -410,131 +440,191 @@ useHead({
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-avatar-img {
|
.profile-avatar-img {
|
||||||
width: 42px;
|
width: 86px;
|
||||||
height: 42px;
|
height: 86px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-initials {
|
.profile-initials {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 14px;
|
font-size: 28px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Identity */
|
||||||
.profile-identity {
|
.profile-identity {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-name {
|
.profile-name {
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 22px;
|
font-size: 42px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.3;
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.profile-member-number {
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-left: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-pronouns-row {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
.profile-pronouns {
|
.profile-pronouns {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-weight: 400;
|
letter-spacing: 0.04em;
|
||||||
margin-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-meta {
|
.profile-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 4px;
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.meta-sep {
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
.profile-studio,
|
||||||
|
.profile-location {
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-sep {
|
/* Social links — vertical stack in hero right column */
|
||||||
color: var(--border);
|
.social-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
.social-link {
|
||||||
.profile-studio {
|
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
transition: border-color 0.15s, color 0.15s, border-style 0.15s;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.social-link:hover {
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--text-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- SECTIONS ---- */
|
/* ====================================================
|
||||||
|
SECTIONS — inside SidebarLayout
|
||||||
|
==================================================== */
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {
|
||||||
padding: 20px 0;
|
padding: 28px 32px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-label {
|
/* Bio: parch (inverted) block */
|
||||||
font-family: "Commit Mono", monospace;
|
.profile-section--parch {
|
||||||
font-size: 10px;
|
background: var(--parch);
|
||||||
text-transform: uppercase;
|
}
|
||||||
letter-spacing: 0.08em;
|
.profile-section--parch .section-label {
|
||||||
color: var(--text-faint);
|
color: var(--parch-text-dim);
|
||||||
margin-bottom: 10px;
|
}
|
||||||
|
.profile-section--parch .profile-bio {
|
||||||
|
color: var(--parch-text);
|
||||||
|
}
|
||||||
|
.profile-section--parch .profile-bio :deep(a) {
|
||||||
|
color: var(--candle-faint);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.profile-section--parch .profile-bio :deep(a:hover) {
|
||||||
|
color: var(--parch-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-bio {
|
.profile-bio {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 1.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-bio :deep(p) {
|
.profile-bio :deep(p) {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-bio :deep(p:last-child) {
|
.profile-bio :deep(p:last-child) {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-bio :deep(a) {
|
.profile-bio :deep(a) {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-bio :deep(a:hover) {
|
.profile-bio :deep(a:hover) {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ====================================================
|
||||||
|
TWO-COLUMN: Craft Tags + Community Connections
|
||||||
|
==================================================== */
|
||||||
|
|
||||||
|
.profile-two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.profile-two-col .profile-section {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.profile-two-col .profile-section:first-child {
|
||||||
|
border-right: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================================================
|
||||||
|
SHARED SECTION ELEMENTS
|
||||||
|
==================================================== */
|
||||||
|
|
||||||
.profile-detail {
|
.profile-detail {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.offering-text,
|
.offering-text,
|
||||||
.looking-text,
|
.looking-text,
|
||||||
.connection-details {
|
.connection-details {
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- TAGS ---- */
|
/* Tags */
|
||||||
.tag-list {
|
.tag-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-pill {
|
.tag-pill {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
padding: 2px 8px;
|
padding: 3px 8px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-pill {
|
.connection-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-state {
|
.connection-state {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
@ -542,97 +632,105 @@ useHead({
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- SOCIAL LINKS ---- */
|
/* ====================================================
|
||||||
.social-links {
|
PEER SUPPORT
|
||||||
display: flex;
|
==================================================== */
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-link {
|
|
||||||
font-family: "Commit Mono", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--candle);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-link:hover {
|
|
||||||
border-color: var(--candle);
|
|
||||||
color: var(--text-bright);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- PEER SUPPORT ---- */
|
|
||||||
.peer-group {
|
.peer-group {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
.peer-group:last-of-type {
|
||||||
.peer-group:last-child {
|
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-label {
|
.peer-label {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.peer-availability {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- ACTIVITY ---- */
|
/* ====================================================
|
||||||
.activity-list {
|
ACTIVITY TIMELINE
|
||||||
|
==================================================== */
|
||||||
|
|
||||||
|
.activity-timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
border-left: 1px dashed var(--border);
|
||||||
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
.activity-entry {
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
font-size: 12px;
|
padding: 8px 0 8px 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
/* Dot connector on the timeline track */
|
||||||
|
.activity-entry::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -4px;
|
||||||
|
top: 14px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-icon {
|
.activity-icon {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
.activity-body {
|
||||||
.activity-text {
|
display: flex;
|
||||||
color: var(--text-dim);
|
flex-direction: column;
|
||||||
flex: 1;
|
gap: 2px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.activity-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.activity-time {
|
.activity-time {
|
||||||
|
font-size: 10px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 11px;
|
letter-spacing: 0.04em;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- AUTH NOTICE ---- */
|
/* ====================================================
|
||||||
|
AUTH NOTICE
|
||||||
|
==================================================== */
|
||||||
|
|
||||||
.auth-notice {
|
.auth-notice {
|
||||||
padding: 20px;
|
border: 1px dashed var(--border);
|
||||||
margin-top: 24px;
|
padding: 24px;
|
||||||
border: 1px dashed var(--candle-faint, var(--border));
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-notice p {
|
.auth-notice p {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- BACK LINK ---- */
|
/* ====================================================
|
||||||
.profile-back {
|
BACK LINK
|
||||||
padding: 24px 0;
|
==================================================== */
|
||||||
}
|
|
||||||
|
|
||||||
|
.profile-back {
|
||||||
|
padding: 24px 32px;
|
||||||
|
}
|
||||||
.back-link {
|
.back-link {
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -640,34 +738,64 @@ useHead({
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link:hover {
|
.back-link:hover {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ====================================================
|
||||||
|
RESPONSIVE
|
||||||
|
==================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
/* SidebarLayout sidebar hides itself at ≤1024px */
|
||||||
|
.profile-two-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.profile-two-col .profile-section:first-child {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.profile-page {
|
.profile-hero,
|
||||||
padding: 0 16px 40px;
|
.profile-hero--with-links {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.profile-hero--with-links .profile-hero-left {
|
||||||
.profile-header {
|
border-right: none;
|
||||||
padding: 20px 0 18px;
|
border-bottom: 1px dashed var(--border);
|
||||||
gap: 12px;
|
}
|
||||||
|
.profile-hero-left {
|
||||||
|
padding: 24px 20px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.profile-hero-right {
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-name {
|
.profile-name {
|
||||||
font-size: 18px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
.profile-avatar {
|
||||||
.profile-pronouns {
|
width: 72px;
|
||||||
display: block;
|
height: 72px;
|
||||||
margin-left: 0;
|
}
|
||||||
margin-top: 2px;
|
.profile-avatar-img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
.profile-initials {
|
||||||
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {
|
||||||
padding: 16px 0;
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.profile-back {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.social-links {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 333 KiB |
|
|
@ -109,19 +109,13 @@ test.describe("keyboard navigation", () => {
|
||||||
test("escape closes login modal", async ({ page }) => {
|
test("escape closes login modal", async ({ page }) => {
|
||||||
await page.goto("/member/dashboard");
|
await page.goto("/member/dashboard");
|
||||||
|
|
||||||
// The page renders an inline "sign in required" wall for unauthenticated users
|
// Auth middleware auto-opens the login modal for unauthenticated users
|
||||||
const signInBlock = page.locator("h2", { hasText: "Sign in required" });
|
const modal = page.getByRole("dialog");
|
||||||
await expect(signInBlock).toBeVisible({ timeout: 10000 });
|
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click the Sign In button to open the login modal overlay
|
|
||||||
await page.locator("button", { hasText: "Sign In" }).click();
|
|
||||||
|
|
||||||
const modal = page.locator("text=Sign in to your dashboard");
|
|
||||||
await expect(modal.first()).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
// Modal should close
|
// Modal should close
|
||||||
await expect(modal.first()).not.toBeVisible({ timeout: 5000 });
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@ test.describe('Authentication flows', () => {
|
||||||
// Navigate to a protected member page without being logged in
|
// Navigate to a protected member page without being logged in
|
||||||
await page.goto('/member/dashboard')
|
await page.goto('/member/dashboard')
|
||||||
|
|
||||||
|
// Modal auto-opens on load; close it via the × button and wait for it to dismiss
|
||||||
|
await page.locator('.modal-close').click()
|
||||||
|
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 5000 })
|
||||||
|
|
||||||
// Page shows the unauth state with sign-in button
|
// Page shows the unauth state with sign-in button
|
||||||
await expect(page.getByRole('heading', { name: 'Sign in required' })).toBeVisible({ timeout: 10000 })
|
await expect(page.getByRole('heading', { name: 'Sign in required' })).toBeVisible({ timeout: 10000 })
|
||||||
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible()
|
||||||
|
|
||||||
// Clicking Sign In opens the login modal with email input
|
// Clicking Sign In re-opens the login modal with email input
|
||||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||||
await expect(page.locator('.modal-title')).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('.modal-title')).toBeVisible({ timeout: 5000 })
|
||||||
await expect(page.locator('input[type="email"]')).toBeVisible()
|
await expect(page.locator('input[type="email"]')).toBeVisible()
|
||||||
|
|
@ -53,6 +57,8 @@ test.describe('Authentication flows', () => {
|
||||||
|
|
||||||
// Navigating to a protected page should show the sign-in prompt
|
// Navigating to a protected page should show the sign-in prompt
|
||||||
await page.goto('/member/dashboard')
|
await page.goto('/member/dashboard')
|
||||||
|
await page.locator('.modal-close').click()
|
||||||
|
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 5000 })
|
||||||
await expect(page.getByRole('heading', { name: 'Sign in required' })).toBeVisible({ timeout: 10000 })
|
await expect(page.getByRole('heading', { name: 'Sign in required' })).toBeVisible({ timeout: 10000 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ test.describe('Member dashboard', () => {
|
||||||
|
|
||||||
// Should show the login modal or the page's sign-in required state
|
// Should show the login modal or the page's sign-in required state
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('.modal-title').or(page.getByText('Sign in required'))
|
page.locator('.modal-title').or(page.getByText('Sign in required')).first()
|
||||||
).toBeVisible({ timeout: 10000 })
|
).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
await context.close()
|
await context.close()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: "2025-07-15",
|
compatibilityDate: "2025-07-15",
|
||||||
devtools: { enabled: process.env.NODE_ENV !== "production" },
|
devtools: { enabled: false },
|
||||||
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
|
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
|
||||||
colorMode: {
|
colorMode: {
|
||||||
preference: "system",
|
preference: "system",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default defineConfig({
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run build && NODE_ENV=development npm run preview",
|
command: "npm run build && NODE_ENV=development npm run preview",
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: !process.env.CI,
|
||||||
env: {
|
env: {
|
||||||
NUXT_PUBLIC_COMING_SOON: "false",
|
NUXT_PUBLIC_COMING_SOON: "false",
|
||||||
NODE_ENV: "development",
|
NODE_ENV: "development",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ async function seedAll() {
|
||||||
console.log("\n📅 Seeding series events...");
|
console.log("\n📅 Seeding series events...");
|
||||||
execSync("node scripts/seed-series-events.js", { stdio: "inherit" });
|
execSync("node scripts/seed-series-events.js", { stdio: "inherit" });
|
||||||
|
|
||||||
|
console.log("\n📋 Seeding pre-registrants...");
|
||||||
|
execSync("node scripts/seed-pre-registrants.js", { stdio: "inherit" });
|
||||||
|
|
||||||
console.log("\n✅ All data seeded successfully!");
|
console.log("\n✅ All data seeded successfully!");
|
||||||
console.log("\n📊 Database Summary:");
|
console.log("\n📊 Database Summary:");
|
||||||
|
|
||||||
|
|
@ -30,12 +33,15 @@ async function seedAll() {
|
||||||
|
|
||||||
const Member = (await import("../server/models/member.js")).default;
|
const Member = (await import("../server/models/member.js")).default;
|
||||||
const Event = (await import("../server/models/event.js")).default;
|
const Event = (await import("../server/models/event.js")).default;
|
||||||
|
const PreRegistration = (await import("../server/models/preRegistration.js")).default;
|
||||||
|
|
||||||
const memberCount = await Member.countDocuments();
|
const memberCount = await Member.countDocuments();
|
||||||
const eventCount = await Event.countDocuments();
|
const eventCount = await Event.countDocuments();
|
||||||
|
const preRegCount = await PreRegistration.countDocuments();
|
||||||
|
|
||||||
console.log(` Members: ${memberCount}`);
|
console.log(` Members: ${memberCount}`);
|
||||||
console.log(` Events: ${eventCount}`);
|
console.log(` Events: ${eventCount}`);
|
||||||
|
console.log(` Pre-registrants: ${preRegCount}`);
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ export default defineEventHandler(async (event) => {
|
||||||
const results = []
|
const results = []
|
||||||
|
|
||||||
for (const preReg of preRegs) {
|
for (const preReg of preRegs) {
|
||||||
// Only send to selected pre-registrants (skip already invited/accepted/expired)
|
// Only send to pending/selected/invited (allow resend); skip accepted/expired
|
||||||
if (preReg.status !== 'selected' && preReg.status !== 'pending') {
|
if (preReg.status !== 'selected' && preReg.status !== 'pending' && preReg.status !== 'invited') {
|
||||||
results.push({
|
results.push({
|
||||||
preRegistrantId: preReg._id,
|
preRegistrantId: preReg._id,
|
||||||
email: preReg.email,
|
email: preReg.email,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken'
|
||||||
import PreRegistration from '../../models/preRegistration.js'
|
import PreRegistration from '../../models/preRegistration.js'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
|
import { assignMemberNumber } from '../../utils/memberNumber.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await validateBody(event, inviteAcceptSchema)
|
const body = await validateBody(event, inviteAcceptSchema)
|
||||||
|
|
@ -47,6 +48,8 @@ export default defineEventHandler(async (event) => {
|
||||||
status: body.contributionTier === '0' ? 'active' : 'pending_payment',
|
status: body.contributionTier === '0' ? 'active' : 'pending_payment',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await assignMemberNumber(member._id)
|
||||||
|
|
||||||
// Update pre-registration
|
// Update pre-registration
|
||||||
await PreRegistration.findByIdAndUpdate(preReg._id, {
|
await PreRegistration.findByIdAndUpdate(preReg._id, {
|
||||||
$set: {
|
$set: {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||||
status: "active",
|
status: "active",
|
||||||
})
|
})
|
||||||
.select(
|
.select(
|
||||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt",
|
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport craftTags communityConnections createdAt memberNumber",
|
||||||
)
|
)
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
|
|
@ -48,6 +48,7 @@ export default defineEventHandler(async (event) => {
|
||||||
name: member.name,
|
name: member.name,
|
||||||
circle: member.circle,
|
circle: member.circle,
|
||||||
createdAt: member.createdAt,
|
createdAt: member.createdAt,
|
||||||
|
memberNumber: member.memberNumber,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check if field should be visible
|
// Helper function to check if field should be visible
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getSlackService } from '../../utils/slack.ts'
|
||||||
import { validateBody } from '../../utils/validateBody.js'
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
import { memberCreateSchema } from '../../utils/schemas.js'
|
import { memberCreateSchema } from '../../utils/schemas.js'
|
||||||
import { sendWelcomeEmail } from '../../utils/resend.js'
|
import { sendWelcomeEmail } from '../../utils/resend.js'
|
||||||
|
import { assignMemberNumber } from '../../utils/memberNumber.js'
|
||||||
// Simple payment check function to avoid import issues
|
// Simple payment check function to avoid import issues
|
||||||
const requiresPayment = (contributionValue) => contributionValue !== '0'
|
const requiresPayment = (contributionValue) => contributionValue !== '0'
|
||||||
|
|
||||||
|
|
@ -101,6 +102,8 @@ export default defineEventHandler(async (event) => {
|
||||||
const member = new Member(validatedData)
|
const member = new Member(validatedData)
|
||||||
await member.save()
|
await member.save()
|
||||||
|
|
||||||
|
await assignMemberNumber(member._id)
|
||||||
|
|
||||||
// Log member joined
|
// Log member joined
|
||||||
logActivity(member._id, 'member_joined', {
|
logActivity(member._id, 'member_joined', {
|
||||||
circle: member.circle
|
circle: member.circle
|
||||||
|
|
|
||||||
8
server/models/counter.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const counterSchema = new mongoose.Schema({
|
||||||
|
_id: String,
|
||||||
|
seq: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default mongoose.models.Counter || mongoose.model('Counter', counterSchema)
|
||||||
|
|
@ -181,6 +181,8 @@ const memberSchema = new mongoose.Schema({
|
||||||
// Session revocation via token versioning
|
// Session revocation via token versioning
|
||||||
tokenVersion: { type: Number, default: 0 },
|
tokenVersion: { type: Number, default: 0 },
|
||||||
|
|
||||||
|
memberNumber: { type: Number, unique: true, sparse: true },
|
||||||
|
|
||||||
createdAt: { type: Date, default: Date.now },
|
createdAt: { type: Date, default: Date.now },
|
||||||
lastLogin: Date,
|
lastLogin: Date,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12
server/utils/memberNumber.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import Counter from '../models/counter.js'
|
||||||
|
import Member from '../models/member.js'
|
||||||
|
|
||||||
|
export async function assignMemberNumber(memberId) {
|
||||||
|
const counter = await Counter.findOneAndUpdate(
|
||||||
|
{ _id: 'memberNumber' },
|
||||||
|
{ $inc: { seq: 1 } },
|
||||||
|
{ new: true, upsert: true }
|
||||||
|
)
|
||||||
|
await Member.findByIdAndUpdate(memberId, { memberNumber: counter.seq }, { runValidators: false })
|
||||||
|
return counter.seq
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,9 @@ vi.mock('../../../server/utils/slack.ts', () => ({
|
||||||
vi.mock('../../../server/utils/resend.js', () => ({
|
vi.mock('../../../server/utils/resend.js', () => ({
|
||||||
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined)
|
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined)
|
||||||
}))
|
}))
|
||||||
|
vi.mock('../../../server/utils/memberNumber.js', () => ({
|
||||||
|
assignMemberNumber: vi.fn().mockResolvedValue(1)
|
||||||
|
}))
|
||||||
|
|
||||||
import Member from '../../../server/models/member.js'
|
import Member from '../../../server/models/member.js'
|
||||||
import { validateBody } from '../../../server/utils/validateBody.js'
|
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||||
|
|
|
||||||
64
tests/server/utils/member-number.test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/counter.js', () => ({
|
||||||
|
default: {
|
||||||
|
findOneAndUpdate: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
|
default: {
|
||||||
|
findByIdAndUpdate: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
import Counter from '../../../server/models/counter.js'
|
||||||
|
import Member from '../../../server/models/member.js'
|
||||||
|
import { assignMemberNumber } from '../../../server/utils/memberNumber.js'
|
||||||
|
|
||||||
|
describe('assignMemberNumber', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
Member.findByIdAndUpdate.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 1 for the first member', async () => {
|
||||||
|
Counter.findOneAndUpdate.mockResolvedValue({ seq: 1 })
|
||||||
|
|
||||||
|
const result = await assignMemberNumber('member-abc')
|
||||||
|
|
||||||
|
expect(result).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('increments atomically using findOneAndUpdate with $inc and upsert', async () => {
|
||||||
|
Counter.findOneAndUpdate.mockResolvedValue({ seq: 5 })
|
||||||
|
|
||||||
|
await assignMemberNumber('member-xyz')
|
||||||
|
|
||||||
|
expect(Counter.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ _id: 'memberNumber' },
|
||||||
|
{ $inc: { seq: 1 } },
|
||||||
|
{ new: true, upsert: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves the member number to the member record without running validators', async () => {
|
||||||
|
Counter.findOneAndUpdate.mockResolvedValue({ seq: 3 })
|
||||||
|
|
||||||
|
await assignMemberNumber('member-abc')
|
||||||
|
|
||||||
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
|
'member-abc',
|
||||||
|
{ memberNumber: 3 },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the sequence number for the given member', async () => {
|
||||||
|
Counter.findOneAndUpdate.mockResolvedValue({ seq: 42 })
|
||||||
|
|
||||||
|
const result = await assignMemberNumber('member-999')
|
||||||
|
|
||||||
|
expect(result).toBe(42)
|
||||||
|
})
|
||||||
|
})
|
||||||