Huge bunch of UI/UX improvements and tweaks!
Some checks failed
Test / vitest (push) Successful in 10m36s
Test / playwright (push) Failing after 9m23s
Test / visual (push) Failing after 9m13s
Test / Notify on failure (push) Successful in 2s

This commit is contained in:
Jennie Robinson Faber 2026-04-06 16:17:12 +01:00
parent 501be10bfe
commit fb25e72215
37 changed files with 1651 additions and 949 deletions

View file

@ -45,7 +45,7 @@ jobs:
env:
PORT: 3000
- 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
- uses: actions/upload-artifact@v4
if: failure()
@ -56,6 +56,18 @@ jobs:
e2e/test-results/
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:
runs-on: ubuntu-latest
needs: vitest
@ -84,7 +96,7 @@ jobs:
env:
PORT: 3000
- 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/
- uses: actions/upload-artifact@v4
if: failure()

View file

@ -50,11 +50,11 @@
--candle: #d4a03a;
--candle-dim: #b8922e;
--candle-faint: #8a7030;
--ember: #c06030;
--ember: #ca6a3a;
--text: #a89880;
--text-bright: #d0c8b0;
--text-dim: #8a7e6a;
--text-faint: #5a5040;
--text-dim: #958774;
--text-faint: #8b7b62;
--parch: #ede4d0;
--parch-hover: #d4c8a8;
--parch-text: #2a2015;
@ -62,7 +62,9 @@
--c-community: #a06850;
--c-founder: #c06030;
--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 ---- */

View file

@ -144,6 +144,15 @@ watch(isOpen, (newValue) => {
loginError.value = ''
}
})
const handleKeydown = (e) => {
if (e.key === 'Escape' && isOpen.value) {
resetAndClose()
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<style scoped>

View file

@ -214,12 +214,14 @@ const currentPageName = computed(() => {
}
.sidebar-brand {
display: block;
display: flex;
align-items: center;
font-family: "Brygada 1918", serif;
font-size: 16px;
font-weight: 600;
color: var(--candle);
padding: 24px 24px 16px;
padding: 0 24px;
height: 53px;
border-bottom: 1px dashed var(--border);
text-decoration: none;
}

View file

@ -447,7 +447,7 @@
<button
type="button"
@click="removeAgendaItem(index)"
class="agenda-remove"
class="link-btn link-btn-danger"
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</button>
@ -949,7 +949,6 @@ const saveAndCreateAnother = async () => {
<style scoped>
.create-form {
max-width: 800px;
margin: 0 auto;
}
.page-header {
@ -972,15 +971,17 @@ const saveAndCreateAnother = async () => {
}
.back-link {
font-size: 12px;
color: var(--candle);
font-size: 11px;
color: var(--text-faint);
text-decoration: none;
margin-bottom: 8px;
display: inline-block;
letter-spacing: 0.02em;
}
.back-link:hover {
text-decoration: underline;
color: var(--candle);
text-decoration: none;
}
.form-body {
@ -1059,6 +1060,8 @@ const saveAndCreateAnother = async () => {
}
.check-label input[type="checkbox"] {
width: auto;
flex-shrink: 0;
margin-top: 2px;
accent-color: var(--candle);
}
@ -1137,16 +1140,22 @@ const saveAndCreateAnother = async () => {
flex: 1;
}
.agenda-remove {
padding: 6px;
color: var(--ember);
.link-btn {
background: none;
border: 1px dashed transparent;
border: none;
color: var(--candle);
cursor: pointer;
font-family: 'Commit Mono', monospace;
font-size: 11px;
padding: 2px 6px;
}
.agenda-remove:hover {
border-color: var(--ember);
.link-btn:hover {
text-decoration: underline;
}
.link-btn-danger {
color: var(--ember);
}
.add-agenda-btn {

View file

@ -27,14 +27,6 @@
<option value="showcase">Showcase</option>
</select>
</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;">
<select v-model="seriesFilter">
<option value="all">All Events</option>
@ -44,8 +36,7 @@
</div>
</div>
<!-- Events Table -->
<div class="table-wrap">
<!-- Loading / Error -->
<div v-if="pending" class="loading-state">
<div class="spinner" />
<span>Loading events...</span>
@ -55,7 +46,15 @@
Error loading events: {{ error }}
</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>
<tr>
<th class="col-title">Title</th>
@ -68,16 +67,11 @@
</tr>
</thead>
<tbody>
<tr v-for="event in filteredEvents" :key="event._id">
<!-- Title -->
<tr v-for="event in upcomingPaged" :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)"
/>
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
</div>
<div>
<span class="event-name">{{ event.title }}</span>
@ -94,27 +88,19 @@
</div>
</div>
</td>
<!-- Type -->
<td>
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</td>
<!-- Date -->
<td class="col-date">
<span class="date-main">{{ formatDate(event.startDate) }}</span>
<span class="date-time">{{ formatTime(event.startDate) }}</span>
</td>
<!-- Status -->
<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>
<!-- Registration -->
<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>
@ -122,8 +108,6 @@
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
</td>
<!-- Tickets -->
<td class="col-tickets">
<template v-if="event.tickets?.enabled">
<span class="ticket-on">Ticketing On</span>
@ -142,14 +126,8 @@
</template>
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
</td>
<!-- Actions -->
<td class="col-actions">
<NuxtLink
:to="`/events/${event.slug || String(event._id)}`"
class="link-btn"
title="View"
>View</NuxtLink>
<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>
@ -158,10 +136,114 @@
</tbody>
</table>
<div v-else class="empty-state">
No events found matching your criteria
<div v-else class="empty-state">No upcoming events matching your filters</div>
<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>
<!-- 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 -->
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
<div class="modal">
@ -199,33 +281,12 @@ const {
const searchQuery = ref('')
const typeFilter = ref('all')
const statusFilter = ref('all')
const seriesFilter = ref('all')
const filteredEvents = computed(() => {
if (!events.value) return []
return events.value.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 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 upcomingPage = ref(1)
const pastPage = ref(1)
const UPCOMING_PAGE_SIZE = 10
const PAST_PAGE_SIZE = 5
const getEventStatus = (event) => {
const now = new Date()
@ -237,6 +298,57 @@ const getEventStatus = (event) => {
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) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
@ -309,10 +421,7 @@ const editEvent = (event) => {
</script>
<style scoped>
.admin-events {
max-width: 1100px;
margin: 0 auto;
}
.admin-events {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -350,9 +459,34 @@ const editEvent = (event) => {
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-wrap {
padding: 0 28px 28px;
padding: 12px 28px 24px;
}
table {
@ -580,6 +714,41 @@ tbody td {
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 ---- */
.loading-state {
text-align: center;
@ -597,7 +766,7 @@ tbody td {
.empty-state {
text-align: center;
padding: 48px 24px;
padding: 32px 24px;
color: var(--text-faint);
font-size: 12px;
}
@ -699,11 +868,15 @@ tbody td {
.filter-bar {
flex-direction: column;
padding: 12px 20px;
padding: 16px 20px;
}
.section-divider {
padding: 16px 20px 0;
}
.table-wrap {
padding: 0 12px 20px;
padding: 12px 20px 20px;
overflow-x: auto;
}

View file

@ -57,7 +57,7 @@
<div v-else-if="recentMembers.length" class="item-list">
<div v-for="member in recentMembers" :key="member._id" class="item-row">
<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>
</div>
<div class="item-meta">
@ -125,10 +125,7 @@ const formatDateTime = (dateString) => {
</script>
<style scoped>
.admin-dash {
max-width: 960px;
margin: 0 auto;
}
.admin-dash {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -241,6 +238,12 @@ const formatDateTime = (dateString) => {
display: block;
color: var(--text);
font-size: 13px;
text-decoration: none;
}
a.item-name:hover {
color: var(--candle);
text-decoration: underline;
}
.item-sub {

View file

@ -1,19 +1,23 @@
<template>
<div class="admin-member-detail">
<!-- 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>
<NuxtLink to="/admin/members" class="back-link"> Members</NuxtLink>
<h1 v-if="member">{{ member.name }}</h1>
<h1 v-else-if="pending">Loading</h1>
<h1 v-else>Member not found</h1>
<p v-if="member" class="member-email">{{ member.email }}</p>
</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="statusClass(member.status)" class="status-badge">{{
member.status
}}</span>
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div>
</div>
</div>
@ -26,6 +30,9 @@
<div v-else-if="fetchError" class="error-state">Failed to load member.</div>
<template v-else-if="member">
<div class="detail-body">
<!-- LEFT COLUMN: form + metadata -->
<div class="detail-left">
<!-- Edit form -->
<section class="detail-section">
<div class="section-label">Member details</div>
@ -85,6 +92,10 @@
<section class="detail-section">
<div class="section-label">Account info</div>
<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">
<dt>Member ID</dt>
<dd class="mono">{{ member._id }}</dd>
@ -122,69 +133,70 @@
<dl class="meta-list">
<div class="meta-row">
<dt>Event reminders</dt>
<dd
:class="
member.notifications?.events !== false
? 'status-ok'
: 'status-dim'
"
>
<dd :class="member.notifications?.events !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.events !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Community updates</dt>
<dd
:class="
member.notifications?.updates !== false
? 'status-ok'
: 'status-dim'
"
>
<dd :class="member.notifications?.updates !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.updates !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Peer support requests</dt>
<dd
:class="
member.notifications?.peerRequests !== false
? 'status-ok'
: 'status-dim'
"
>
<dd :class="member.notifications?.peerRequests !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
</dd>
</div>
</dl>
</section>
</div>
<!-- Full Activity Log -->
<section class="detail-section">
<div class="section-label">Full Activity Log</div>
<!-- RIGHT COLUMN: activity log -->
<div class="detail-right">
<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>
<div v-if="activityLoading && !activityEntries.length" class="loading-state">
<div v-if="activityLoading && !activityEntries.length" class="activity-loading">
<div class="spinner" />
Loading activity...
</div>
<div v-else-if="activityEntries.length" class="activity-log">
<div v-for="entry in activityEntries" :key="entry._id" class="al-item" :class="{ 'al-admin': entry.visibility === 'admin' }">
<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 class="al-dot" />
<div class="al-body">
<div class="al-row">
<UIcon :name="getActivity(entry).icon" class="al-icon" />
<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>
</div>
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
</div>
</div>
<div v-if="activityHasMore" class="al-load-more">
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
{{ activityLoadingMore ? 'Loading...' : 'Load More' }}
{{ activityLoadingMore ? 'Loading...' : 'Load more' }}
</button>
</div>
</div>
<div v-else class="loading-state">
<div v-else class="activity-empty">
No activity recorded.
</div>
</ClientOnly>
</section>
</div>
</div>
</div>
</template>
</div>
</template>
@ -271,12 +283,12 @@ async function submitEdit() {
member.value = { ...member.value, ...updated, role: form.role };
pageBreadcrumbTitle.value = form.name;
}
toast.add({ title: "Member updated", color: "green" });
toast.add({ title: "Member updated", color: "success" });
} catch (err) {
toast.add({
title: "Failed to update member",
description: err.data?.statusMessage || err.message,
color: "red",
color: "error",
});
} finally {
saving.value = false;
@ -344,13 +356,42 @@ onMounted(loadActivity)
</script>
<style scoped>
.admin-member-detail {
max-width: 640px;
padding: 32px 40px 60px;
.admin-member-detail {}
/* ---- PAGE HEADER ---- */
.page-header {
padding: 28px 28px 20px;
border-bottom: 1px dashed var(--border);
}
.page-header {
margin-bottom: 32px;
.header-nav {
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 {
@ -360,26 +401,13 @@ onMounted(loadActivity)
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 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 600;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 4px;
line-height: 1.2;
}
.member-email {
@ -388,29 +416,44 @@ onMounted(loadActivity)
margin: 0;
}
.header-actions {
.header-badges {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding-top: 6px;
padding-top: 4px;
}
.status-badge {
font-size: 11px;
font-size: 10px;
font-family: "Commit Mono", monospace;
padding: 2px 8px;
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 {
margin-bottom: 40px;
padding: 24px 28px;
border-bottom: 1px dashed var(--border);
}
.edit-form {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
margin-top: 12px;
}
@ -418,6 +461,8 @@ onMounted(loadActivity)
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px dashed var(--border);
}
.meta-list {
@ -430,8 +475,8 @@ onMounted(loadActivity)
.meta-row {
display: flex;
gap: 24px;
padding: 10px 14px;
gap: 16px;
padding: 9px 14px;
border-bottom: 1px dashed var(--border);
}
@ -452,6 +497,7 @@ onMounted(loadActivity)
font-size: 12px;
color: var(--text);
margin: 0;
word-break: break-all;
}
.mono {
@ -459,8 +505,9 @@ onMounted(loadActivity)
font-size: 11px;
}
/* ---- STATUS ---- */
.status-ok {
color: var(--c-founder);
color: var(--green);
}
.status-dim {
@ -471,6 +518,7 @@ onMounted(loadActivity)
color: var(--ember);
}
/* ---- STATES ---- */
.loading-state,
.error-state {
display: flex;
@ -478,46 +526,122 @@ onMounted(loadActivity)
gap: 12px;
color: var(--text-dim);
font-size: 13px;
padding: 40px 0;
padding: 40px 28px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--candle);
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
to { transform: rotate(360deg); }
}
/* ---- ACTIVITY LOG ---- */
.activity-log {
margin-top: 12px;
border: 1px dashed var(--border);
/* ---- ACTIVITY PANEL ---- */
.detail-right {
position: relative;
}
.al-item {
.activity-panel {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 120px);
}
.activity-panel-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
justify-content: space-between;
padding: 24px 28px 16px;
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;
}
.al-item:last-child {
border-bottom: none;
.activity-empty {
padding: 32px 28px;
color: var(--text-faint);
font-size: 12px;
}
.al-admin {
opacity: 0.7;
/* Timeline */
.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 {
@ -525,41 +649,75 @@ onMounted(loadActivity)
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
margin-top: 1px;
}
.al-text {
flex: 1;
min-width: 0;
color: var(--text-dim);
color: var(--text);
font-size: 12px;
line-height: 1.4;
}
.al-time {
display: block;
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
font-size: 10px;
margin-top: 3px;
letter-spacing: 0.02em;
}
.al-vis-badge {
font-size: 9px;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 4px;
color: var(--candle);
border: 1px dashed var(--candle-faint);
padding: 1px 5px;
flex-shrink: 0;
letter-spacing: 0.04em;
}
.al-load-more {
display: flex;
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) {
.admin-member-detail {
padding: 20px 16px 40px;
.page-header {
padding: 24px 20px 16px;
}
.header-row {
.detail-section {
padding: 20px;
}
.activity-panel-header {
padding: 16px 20px 12px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.al-item {
padding: 0 20px 0 14px;
}
.meta-row {

View file

@ -55,18 +55,15 @@
<thead>
<tr>
<th class="col-check">
<UCheckbox
label="Select all members"
:ui="{ label: 'sr-only' }"
:model-value="
allVisibleSelected
? true
: someVisibleSelected
? 'indeterminate'
: false
"
@update:model-value="toggleSelectAll"
<label class="custom-check" aria-label="Select all members">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
<span class="check-mark" />
</label>
</th>
<th>Name</th>
<th>Email</th>
@ -79,16 +76,26 @@
</tr>
</thead>
<tbody>
<tr v-for="member in filteredMembers" :key="member._id">
<td class="col-check">
<UCheckbox
:label="`Select ${member.name}`"
:ui="{ label: 'sr-only' }"
:model-value="selectedMemberIds.includes(member._id)"
@update:model-value="toggleSelect(member._id)"
<tr
v-for="member in filteredMembers"
:key="member._id"
class="selectable-row"
:class="{ 'row-selected': selectedMemberIds.includes(member._id) }"
@click="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 class="col-name">{{ member.name }}</td>
<td class="col-email">{{ member.email }}</td>
<td>
<span class="badge" :class="member.circle">{{
@ -112,13 +119,13 @@
{{ formatDate(member.createdAt) }}
</td>
<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
>
<button @click="sendSlackInvite(member)" class="link-btn">
<button @click.stop="sendSlackInvite(member)" class="link-btn">
Slack
</button>
<button @click="editMember(member)" class="link-btn">Edit</button>
<button @click.stop="editMember(member)" class="link-btn">Edit</button>
</td>
</tr>
</tbody>
@ -602,13 +609,13 @@ const createMember = async () => {
});
await refresh();
toast.add({ title: "Member created", color: "green" });
toast.add({ title: "Member created", color: "success" });
} catch (err) {
console.error("Failed to create member:", err);
toast.add({
title: "Failed to create member",
description: err.data?.statusMessage || err.message,
color: "red",
color: "error",
});
} finally {
creating.value = false;
@ -718,14 +725,14 @@ const submitImport = async () => {
toast.add({
title: `Imported ${result.created} member${result.created !== 1 ? "s" : ""}`,
description: result.failed ? `${result.failed} failed` : undefined,
color: result.failed ? "orange" : "green",
color: result.failed ? "warning" : "success",
});
} catch (err) {
console.error("Import failed:", err);
toast.add({
title: "Import failed",
description: err.data?.statusMessage || err.message,
color: "red",
color: "error",
});
} finally {
importing.value = false;
@ -757,14 +764,14 @@ const submitInvites = async () => {
toast.add({
title: `Sent ${result.sent} invite${result.sent !== 1 ? "s" : ""}`,
description: result.failed ? `${result.failed} failed` : undefined,
color: result.failed ? "orange" : "green",
color: result.failed ? "warning" : "success",
});
} catch (err) {
console.error("Failed to send invites:", err);
toast.add({
title: "Failed to send invites",
description: err.data?.statusMessage || err.message,
color: "red",
color: "error",
});
} finally {
sendingInvites.value = false;
@ -815,12 +822,12 @@ const submitEditMember = async () => {
});
showEditModal.value = false;
await refresh();
toast.add({ title: "Member updated", color: "green" });
toast.add({ title: "Member updated", color: "success" });
} catch (err) {
toast.add({
title: "Failed to update member",
description: err.data?.statusMessage || err.message,
color: "red",
color: "error",
});
} finally {
saving.value = false;
@ -829,10 +836,7 @@ const submitEditMember = async () => {
</script>
<style scoped>
.admin-members {
max-width: 1100px;
margin: 0 auto;
}
.admin-members {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -914,16 +918,94 @@ tbody td {
}
.col-check {
width: 32px;
padding-left: 0;
width: 40px;
padding-left: 12px;
padding-right: 4px;
}
.selectable-row {
cursor: pointer;
}
.row-selected {
background: var(--surface);
}
/* ---- CUSTOM CHECKBOX ---- */
.custom-check {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
width: 16px;
height: 16px;
}
.custom-check input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.check-mark {
width: 14px;
height: 14px;
border: 1px solid var(--border);
background: var(--input-bg);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
}
.custom-check:hover .check-mark {
border-color: var(--candle);
}
.custom-check input:checked + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:checked + .check-mark::after {
content: "";
width: 4px;
height: 8px;
border: solid var(--bg);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg) translateY(-1px);
}
.custom-check input:indeterminate + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:indeterminate + .check-mark::after {
content: "";
width: 8px;
height: 0;
border-bottom: 1.5px solid var(--bg);
}
.col-name {
font-weight: 500;
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 {
color: var(--text-dim);
font-size: 11px;
@ -1184,11 +1266,11 @@ tbody td {
.filter-bar {
flex-direction: column;
padding: 12px 20px;
padding: 16px 20px;
}
.table-wrap {
padding: 0 12px 20px;
padding: 0 20px 20px;
overflow-x: auto;
}

View file

@ -65,103 +65,66 @@
<thead>
<tr>
<th class="col-check">
<UCheckbox
label="Select all"
:ui="{ label: 'sr-only' }"
:model-value="
allVisibleSelected
? true
: someVisibleSelected
? 'indeterminate'
: false
"
@update:model-value="toggleSelectAll"
<label class="custom-check" aria-label="Select all">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
<span class="check-mark" />
</label>
</th>
<th>Name</th>
<th>Email</th>
<th>City</th>
<th>Role</th>
<th>Status</th>
<th class="col-date">Registered</th>
<th class="sortable" @click="toggleSort('name')">Name <span v-if="sortKey === 'name'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('email')">Email <span v-if="sortKey === 'email'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('city')">City <span v-if="sortKey === 'city'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('role')">Role <span v-if="sortKey === 'role'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('status')">Status <span v-if="sortKey === 'status'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable col-date" @click="toggleSort('createdAt')">Registered <span v-if="sortKey === 'createdAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
</tr>
</thead>
<tbody>
<template v-for="pr in filtered" :key="pr._id">
<tr
:class="{ 'row-expanded': expandedId === pr._id }"
@click="toggleExpand(pr._id)"
style="cursor: pointer"
v-for="pr in filtered"
:key="pr._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(pr._id) }"
@click="toggleSelect(pr._id)"
>
<td class="col-check" @click.stop>
<UCheckbox
:label="`Select ${pr.name || pr.email}`"
:ui="{ label: 'sr-only' }"
:model-value="selectedIds.includes(pr._id)"
@update:model-value="toggleSelect(pr._id)"
<label class="custom-check" :aria-label="`Select ${pr.name || pr.email}`">
<input
type="checkbox"
:checked="selectedIds.includes(pr._id)"
@change="toggleSelect(pr._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-name">{{ pr.name || "—" }}</td>
<td class="col-email">{{ pr.email }}</td>
<td>{{ pr.city || "—" }}</td>
<td>{{ pr.role || "—" }}</td>
<td>
<span class="status-badge" :class="`status-${pr.status}`">
{{ pr.status }}
</span>
<td @click.stop>
<select
class="inline-status"
: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 class="col-mono col-date">
{{ formatDate(pr.createdAt) }}
</td>
</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>
</table>
@ -267,10 +230,9 @@ const { data: stats, refresh: refreshStats } = await useFetch(
const searchQuery = ref("");
const statusFilter = ref("");
const selectedIds = ref([]);
const expandedId = ref(null);
const editNotes = ref("");
const editStatus = ref("pending");
const savingDetail = ref(false);
const savingId = ref(null);
const sortKey = ref("");
const sortDir = ref("asc");
// Invite
const showInviteModal = ref(false);
@ -291,10 +253,19 @@ See you inside.`;
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(() => {
if (!preRegistrants.value) return [];
return preRegistrants.value.filter((pr) => {
const result = preRegistrants.value.filter((pr) => {
const q = searchQuery.value.toLowerCase();
const matchesSearch =
!q ||
@ -307,6 +278,18 @@ const filtered = computed(() => {
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
@ -319,12 +302,12 @@ const someVisibleSelected = computed(() => {
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(() => {
if (!preRegistrants.value) return [];
return selectedIds.value.filter((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 toggleExpand = (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;
const updateStatus = async (id, newStatus) => {
savingId.value = id;
try {
await $fetch(`/api/admin/pre-registrants/${id}`, {
method: "PUT",
body: { status: editStatus.value, adminNotes: editNotes.value },
body: { status: newStatus },
});
await refresh();
await refreshStats();
toast.add({ title: "Updated", color: "green" });
toast.add({ title: "Status updated", color: "green" });
} catch (err) {
toast.add({
title: "Failed to update",
@ -379,7 +350,7 @@ const saveDetail = async (id) => {
color: "red",
});
} finally {
savingDetail.value = false;
savingId.value = null;
}
};
@ -459,10 +430,7 @@ const formatDate = (dateString) => {
</script>
<style scoped>
.admin-prereg {
max-width: 1100px;
margin: 0 auto;
}
.admin-prereg {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -525,6 +493,21 @@ thead th {
letter-spacing: 0.05em;
color: var(--text-faint);
border-bottom: 1px dashed var(--border);
font-weight: normal;
}
thead th.sortable {
cursor: pointer;
user-select: none;
}
thead th.sortable:hover {
color: var(--text-dim);
}
.sort-arrow {
font-size: 10px;
color: var(--candle);
}
tbody tr {
@ -543,11 +526,78 @@ tbody td {
}
.col-check {
width: 32px;
padding-left: 0;
width: 40px;
padding-left: 12px;
padding-right: 4px;
}
.selectable-row {
cursor: pointer;
}
.row-selected {
background: var(--surface);
}
/* ---- CUSTOM CHECKBOX ---- */
.custom-check {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
width: 16px;
height: 16px;
}
.custom-check input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.check-mark {
width: 14px;
height: 14px;
border: 1px solid var(--border);
background: var(--input-bg);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
}
.custom-check:hover .check-mark {
border-color: var(--candle);
}
.custom-check input:checked + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:checked + .check-mark::after {
content: "";
width: 4px;
height: 8px;
border: solid var(--bg);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg) translateY(-1px);
}
.custom-check input:indeterminate + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:indeterminate + .check-mark::after {
content: "";
width: 8px;
height: 0;
border-bottom: 1.5px solid var(--bg);
}
.col-name {
font-weight: 500;
color: var(--text-bright);
@ -600,42 +650,21 @@ tbody td {
border-color: var(--ember);
}
/* ---- EXPANDED DETAIL ROW ---- */
.row-expanded {
background: var(--surface);
/* ---- INLINE STATUS SELECT ---- */
.inline-status {
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 {
padding: 0 10px 16px;
border-bottom: 1px dashed var(--border);
}
.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;
.inline-status:disabled {
opacity: 0.5;
cursor: wait;
}
/* ---- STATUS INDICATORS ---- */
@ -822,9 +851,5 @@ tbody td {
table {
min-width: 600px;
}
.detail-fields {
flex-direction: column;
}
}
</style>

View file

@ -64,7 +64,7 @@
<div class="series-title-row">
<div>
<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>
</div>
<div class="series-meta">
@ -171,15 +171,15 @@
</div>
<div class="modal-body">
<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>
<span>Fix position numbers based on event dates</span>
</button>
<button @click="validateAllSeries" class="bulk-action">
<button @click="validateAllSeries" class="btn bulk-action">
<strong>Validate Series Data</strong>
<span>Check for consistency issues</span>
</button>
<button @click="exportSeriesData" class="bulk-action">
<button @click="exportSeriesData" class="btn bulk-action">
<strong>Export Series Data</strong>
<span>Download series information as JSON</span>
</button>
@ -714,10 +714,7 @@ const exportSeriesData = () => {
</script>
<style scoped>
.admin-series {
max-width: 1100px;
margin: 0 auto;
}
.admin-series {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -813,7 +810,7 @@ const exportSeriesData = () => {
gap: 16px;
}
.series-header h3 {
.series-header h2 {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 500;
@ -1217,7 +1214,7 @@ const exportSeriesData = () => {
}
.series-list {
padding: 16px 12px;
padding: 20px 20px;
}
.series-header,

View file

@ -251,7 +251,6 @@ const createAndAddEvent = async () => {
<style scoped>
.create-form {
max-width: 800px;
margin: 0 auto;
}
.page-header {
@ -271,13 +270,14 @@ const createAndAddEvent = async () => {
.page-header p { font-size: 12px; color: var(--text-dim); }
.back-link {
font-size: 12px;
color: var(--candle);
font-size: 11px;
color: var(--text-faint);
text-decoration: none;
margin-bottom: 8px;
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; }

View file

@ -13,9 +13,13 @@
</div>
<!-- Profile Content -->
<div v-else class="profile-content">
<!-- Header Area -->
<div class="profile-header">
<template v-else>
<!-- 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">
<img
v-if="member.avatar"
@ -23,64 +27,109 @@
:alt="member.name"
class="profile-avatar-img"
/>
<span v-else class="profile-initials">{{
getInitials(member.name)
}}</span>
<span v-else class="profile-initials">{{ getInitials(member.name) }}</span>
</div>
<div class="profile-identity">
<h1 class="profile-name">
{{ member.name }}
<span v-if="member.pronouns" class="profile-pronouns">{{
member.pronouns
}}</span>
{{ member.name }}<span v-if="member.memberNumber" class="profile-member-number">#{{ member.memberNumber }}</span>
</h1>
<div v-if="member.pronouns" class="profile-pronouns-row">
<span class="profile-pronouns">{{ member.pronouns }}</span>
</div>
<div class="profile-meta">
<span v-if="member.circle" class="badge" :class="member.circle">{{
circleLabels[member.circle]
}}</span>
<span v-if="member.circle" class="badge" :class="member.circle">
{{ circleLabels[member.circle] }}
</span>
<template v-if="member.studio">
<span class="meta-sep">&middot;</span>
<span class="profile-studio">{{ member.studio }}</span>
</template>
<template v-if="member.location || member.timeZone">
<span class="meta-sep">&middot;</span>
<span class="profile-location">
{{ [member.location, member.timeZone].filter(Boolean).join(' · ') }}
</span>
</template>
</div>
</div>
</div>
<!-- Bio Section -->
<div v-if="member.bio" class="profile-section">
<!-- Right: Social Links (only when present) -->
<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="profile-bio" v-html="renderMarkdown(member.bio)"></div>
</div>
<!-- Location & Timezone -->
<div v-if="member.location || member.timeZone" class="profile-section">
<div class="section-label">Location</div>
<p class="profile-detail">
{{ [member.location, member.timeZone].filter(Boolean).join(" · ") }}
</p>
</div>
<!-- What I Do (craft tags, falling back to offering) -->
<div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section">
<!-- Two-column: Craft Tags + Community Connections -->
<div
v-if="craftTagsDisplay.length > 0 || member.offering?.text || connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
class="profile-two-col"
>
<!-- Left: What I Do -->
<div class="profile-section">
<div class="section-label">What I Do</div>
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
<span
v-for="tag in craftTagsDisplay"
:key="tag"
class="tag-pill"
>{{ tagLabel('craft', tag) }}</span
>
>{{ tagLabel('craft', tag) }}</span>
</div>
<p v-if="member.offering?.text" class="profile-detail offering-text">
{{ member.offering.text }}
</p>
</div>
<!-- Community Connections (cooperative topics with states, falling back to lookingFor) -->
<div
v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
class="profile-section"
>
<!-- Right: Community Connections -->
<div class="profile-section">
<div class="section-label">Community Connections</div>
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
<span
@ -99,123 +148,74 @@
{{ member.lookingFor.text }}
</p>
</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>
<!-- Peer Support Section (reads from communityConnections, falls back to peerSupport) -->
<!-- Peer Support -->
<div v-if="showPeerSupport" class="profile-section">
<div class="section-label">Peer Support</div>
<div class="dashed-box no-hover">
<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">
<span
v-for="topic in member.peerSupport.skillTopics"
:key="topic"
class="tag-pill"
>{{ topic }}</span
>
>{{ topic }}</span>
</div>
</div>
<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">
<span
v-for="topic in member.peerSupport.supportTopics"
:key="topic"
class="tag-pill"
>{{ topic }}</span
>
>{{ topic }}</span>
</div>
</div>
<p v-if="peerAvailability" class="profile-detail">
<p v-if="peerAvailability" class="profile-detail peer-availability">
{{ peerAvailability }}
</p>
</div>
</div>
<!-- Recent Activity -->
<div v-if="activityEntries.length" class="profile-section">
<div class="section-label">Recent Activity</div>
<div class="activity-list">
<div v-for="entry in activityEntries" :key="entry._id" class="activity-item">
<div class="activity-timeline">
<div v-for="entry in activityEntries" :key="entry._id" class="activity-entry">
<UIcon :name="getActivity(entry).icon" class="activity-icon" />
<div class="activity-body">
<span class="activity-text">{{ getActivity(entry).text }}</span>
<span class="activity-time">{{ formatRelativeDate(entry.timestamp) }}</span>
</div>
</div>
</div>
</div>
<!-- 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>
<button
type="button"
class="btn"
@click="
openLoginModal({
title: 'Sign in to see more',
description: 'Log in to view full member profiles',
})
"
@click="openLoginModal({ title: 'Sign in to see more', description: 'Log in to view full member profiles' })"
>
Log In
</button>
</div>
</div>
<!-- Back Link -->
<div class="profile-back">
<NuxtLink to="/members" class="back-link"> Back to Members</NuxtLink>
</div>
</div>
</SidebarLayout>
</template>
</div>
</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", () => "");
watch(
member,
@ -356,52 +361,77 @@ useHead({
</script>
<style scoped>
/* ====================================================
PROFILE PAGE
Full-bleed layout: no max-width, no centering.
Flex chain enables SidebarLayout's flex: 1 to work.
==================================================== */
.profile-page {
max-width: 720px;
margin: 0 auto;
padding: 0 24px 60px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- LOADING ---- */
/* ---- LOADING STATE ---- */
.loading-state {
padding: 80px 24px;
padding: 80px 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: "Commit Mono", monospace;
}
/* ---- ERROR / 404 ---- */
/* ---- ERROR / 404 STATE ---- */
.error-state {
padding: 80px 24px;
padding: 80px 32px;
text-align: center;
}
.error-title {
font-family: "Brygada 1918", serif;
font-size: 20px;
color: var(--text-dim);
margin-bottom: 6px;
}
.error-sub {
font-size: 12px;
color: var(--text-faint);
margin-bottom: 20px;
}
/* ---- HEADER ---- */
.profile-header {
display: flex;
align-items: center;
gap: 16px;
padding: 28px 0 24px;
/* ====================================================
HERO full-bleed, two-column when social links exist
==================================================== */
.profile-hero {
display: grid;
grid-template-columns: 1fr;
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 {
width: 48px;
height: 48px;
width: 96px;
height: 96px;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
@ -410,131 +440,191 @@ useHead({
flex-shrink: 0;
overflow: hidden;
}
.profile-avatar-img {
width: 42px;
height: 42px;
width: 86px;
height: 86px;
object-fit: contain;
}
.profile-initials {
font-family: "Commit Mono", monospace;
font-size: 14px;
font-size: 28px;
color: var(--text-faint);
font-weight: 600;
}
/* Identity */
.profile-identity {
min-width: 0;
}
.profile-name {
font-family: "Brygada 1918", serif;
font-size: 22px;
font-size: 42px;
font-weight: 600;
color: var(--text-bright);
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 {
font-family: "Commit Mono", monospace;
font-size: 12px;
font-size: 11px;
color: var(--text-faint);
font-weight: 400;
margin-left: 8px;
letter-spacing: 0.04em;
}
.profile-meta {
display: flex;
align-items: center;
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;
color: var(--text-dim);
}
.meta-sep {
color: var(--border);
/* Social links — vertical stack in hero right column */
.social-links {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.profile-studio {
.social-link {
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 {
padding: 20px 0;
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.section-label {
font-family: "Commit Mono", monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
margin-bottom: 10px;
/* Bio: parch (inverted) block */
.profile-section--parch {
background: var(--parch);
}
.profile-section--parch .section-label {
color: var(--parch-text-dim);
}
.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 {
font-size: 13px;
line-height: 1.75;
color: var(--text-dim);
line-height: 1.7;
}
.profile-bio :deep(p) {
margin: 0 0 8px;
margin: 0 0 10px;
}
.profile-bio :deep(p:last-child) {
margin-bottom: 0;
}
.profile-bio :deep(a) {
color: var(--candle);
text-decoration: underline;
text-underline-offset: 2px;
}
.profile-bio :deep(a:hover) {
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 {
font-size: 13px;
color: var(--text-dim);
line-height: 1.6;
margin: 0;
}
.offering-text,
.looking-text,
.connection-details {
margin-top: 8px;
margin-top: 10px;
}
/* ---- TAGS ---- */
/* Tags */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-pill {
font-family: "Commit Mono", monospace;
font-size: 10px;
color: var(--text-dim);
padding: 2px 8px;
padding: 3px 8px;
border: 1px dashed var(--border);
white-space: nowrap;
}
.connection-pill {
display: inline-flex;
align-items: center;
gap: 4px;
}
.connection-state {
font-size: 9px;
text-transform: uppercase;
@ -542,97 +632,105 @@ useHead({
color: var(--text-faint);
}
/* ---- SOCIAL LINKS ---- */
.social-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* ====================================================
PEER SUPPORT
==================================================== */
.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 {
margin-bottom: 10px;
margin-bottom: 14px;
}
.peer-group:last-child {
.peer-group:last-of-type {
margin-bottom: 0;
}
.peer-label {
font-family: "Commit Mono", monospace;
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
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;
flex-direction: column;
gap: 8px;
border-left: 1px dashed var(--border);
margin-left: 6px;
}
.activity-item {
.activity-entry {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
align-items: flex-start;
gap: 10px;
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 {
width: 14px;
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
margin-top: 1px;
}
.activity-text {
color: var(--text-dim);
flex: 1;
.activity-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.activity-text {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.activity-time {
font-size: 10px;
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
letter-spacing: 0.04em;
}
/* ---- AUTH NOTICE ---- */
/* ====================================================
AUTH NOTICE
==================================================== */
.auth-notice {
padding: 20px;
margin-top: 24px;
border: 1px dashed var(--candle-faint, var(--border));
border: 1px dashed var(--border);
padding: 24px;
text-align: center;
}
.auth-notice p {
font-size: 12px;
color: var(--text-dim);
margin: 0 0 12px;
}
/* ---- BACK LINK ---- */
.profile-back {
padding: 24px 0;
}
/* ====================================================
BACK LINK
==================================================== */
.profile-back {
padding: 24px 32px;
}
.back-link {
font-family: "Commit Mono", monospace;
font-size: 12px;
@ -640,34 +738,64 @@ useHead({
text-decoration: none;
transition: color 0.15s;
}
.back-link:hover {
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) {
.profile-page {
padding: 0 16px 40px;
.profile-hero,
.profile-hero--with-links {
grid-template-columns: 1fr;
}
.profile-header {
padding: 20px 0 18px;
gap: 12px;
.profile-hero--with-links .profile-hero-left {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.profile-hero-left {
padding: 24px 20px;
gap: 16px;
}
.profile-hero-right {
padding: 20px;
}
.profile-name {
font-size: 18px;
font-size: 28px;
}
.profile-pronouns {
display: block;
margin-left: 0;
margin-top: 2px;
.profile-avatar {
width: 72px;
height: 72px;
}
.profile-avatar-img {
width: 64px;
height: 64px;
}
.profile-initials {
font-size: 20px;
}
.profile-section {
padding: 16px 0;
padding: 20px;
}
.profile-back {
padding: 20px;
}
.social-links {
flex-direction: row;
flex-wrap: wrap;
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Before After
Before After

View file

@ -109,19 +109,13 @@ test.describe("keyboard navigation", () => {
test("escape closes login modal", async ({ page }) => {
await page.goto("/member/dashboard");
// The page renders an inline "sign in required" wall for unauthenticated users
const signInBlock = page.locator("h2", { hasText: "Sign in required" });
await expect(signInBlock).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 });
// Auth middleware auto-opens the login modal for unauthenticated users
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible({ timeout: 10000 });
await page.keyboard.press("Escape");
// Modal should close
await expect(modal.first()).not.toBeVisible({ timeout: 5000 });
await expect(modal).not.toBeVisible({ timeout: 5000 });
});
});

View file

@ -6,11 +6,15 @@ test.describe('Authentication flows', () => {
// Navigate to a protected member page without being logged in
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
await expect(page.getByRole('heading', { name: 'Sign in required' })).toBeVisible({ timeout: 10000 })
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 expect(page.locator('.modal-title')).toBeVisible({ timeout: 5000 })
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
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 })
})
})

View file

@ -28,7 +28,7 @@ test.describe('Member dashboard', () => {
// Should show the login modal or the page's sign-in required state
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 })
await context.close()

View file

@ -1,7 +1,7 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: process.env.NODE_ENV !== "production" },
devtools: { enabled: false },
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
colorMode: {
preference: "system",

View file

@ -24,7 +24,7 @@ export default defineConfig({
webServer: {
command: "npm run build && NODE_ENV=development npm run preview",
url: "http://localhost:3000",
reuseExistingServer: false,
reuseExistingServer: !process.env.CI,
env: {
NUXT_PUBLIC_COMING_SOON: "false",
NODE_ENV: "development",

View file

@ -22,6 +22,9 @@ async function seedAll() {
console.log("\n📅 Seeding series events...");
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📊 Database Summary:");
@ -30,12 +33,15 @@ async function seedAll() {
const Member = (await import("../server/models/member.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 eventCount = await Event.countDocuments();
const preRegCount = await PreRegistration.countDocuments();
console.log(` Members: ${memberCount}`);
console.log(` Events: ${eventCount}`);
console.log(` Pre-registrants: ${preRegCount}`);
process.exit(0);
} catch (error) {

View file

@ -26,8 +26,8 @@ export default defineEventHandler(async (event) => {
const results = []
for (const preReg of preRegs) {
// Only send to selected pre-registrants (skip already invited/accepted/expired)
if (preReg.status !== 'selected' && preReg.status !== 'pending') {
// Only send to pending/selected/invited (allow resend); skip accepted/expired
if (preReg.status !== 'selected' && preReg.status !== 'pending' && preReg.status !== 'invited') {
results.push({
preRegistrantId: preReg._id,
email: preReg.email,

View file

@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken'
import PreRegistration from '../../models/preRegistration.js'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import { assignMemberNumber } from '../../utils/memberNumber.js'
export default defineEventHandler(async (event) => {
const body = await validateBody(event, inviteAcceptSchema)
@ -47,6 +48,8 @@ export default defineEventHandler(async (event) => {
status: body.contributionTier === '0' ? 'active' : 'pending_payment',
})
await assignMemberNumber(member._id)
// Update pre-registration
await PreRegistration.findByIdAndUpdate(preReg._id, {
$set: {

View file

@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
status: "active",
})
.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();
@ -48,6 +48,7 @@ export default defineEventHandler(async (event) => {
name: member.name,
circle: member.circle,
createdAt: member.createdAt,
memberNumber: member.memberNumber,
};
// Helper function to check if field should be visible

View file

@ -5,6 +5,7 @@ import { getSlackService } from '../../utils/slack.ts'
import { validateBody } from '../../utils/validateBody.js'
import { memberCreateSchema } from '../../utils/schemas.js'
import { sendWelcomeEmail } from '../../utils/resend.js'
import { assignMemberNumber } from '../../utils/memberNumber.js'
// Simple payment check function to avoid import issues
const requiresPayment = (contributionValue) => contributionValue !== '0'
@ -101,6 +102,8 @@ export default defineEventHandler(async (event) => {
const member = new Member(validatedData)
await member.save()
await assignMemberNumber(member._id)
// Log member joined
logActivity(member._id, 'member_joined', {
circle: member.circle

8
server/models/counter.js Normal file
View 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)

View file

@ -181,6 +181,8 @@ const memberSchema = new mongoose.Schema({
// Session revocation via token versioning
tokenVersion: { type: Number, default: 0 },
memberNumber: { type: Number, unique: true, sparse: true },
createdAt: { type: Date, default: Date.now },
lastLogin: Date,
});

View 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
}

View file

@ -22,6 +22,9 @@ vi.mock('../../../server/utils/slack.ts', () => ({
vi.mock('../../../server/utils/resend.js', () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('../../../server/utils/memberNumber.js', () => ({
assignMemberNumber: vi.fn().mockResolvedValue(1)
}))
import Member from '../../../server/models/member.js'
import { validateBody } from '../../../server/utils/validateBody.js'

View 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)
})
})