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: 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()

View file

@ -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 ---- */

View file

@ -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>

View file

@ -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;
} }

View file

@ -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 {

View file

@ -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,124 +36,214 @@
</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>
</div>
<div v-else-if="error" class="error-state">
Error loading events: {{ error }}
</div>
<table v-else-if="filteredEvents.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 filteredEvents" :key="event._id">
<!-- Title -->
<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>
<!-- 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>
<span v-if="event.maxAttendees" class="reg-count">
{{ 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>
<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>
<!-- Actions -->
<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 events found matching your criteria
</div>
</div> </div>
<div v-else-if="error" class="error-state">
Error loading events: {{ error }}
</div>
<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>
<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 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)" />
</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 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 --> <!-- 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;
} }

View file

@ -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 {

View file

@ -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,165 +30,173 @@
<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">
<!-- Edit form --> <div class="detail-body">
<section class="detail-section"> <!-- LEFT COLUMN: form + metadata -->
<div class="section-label">Member details</div> <div class="detail-left">
<form class="edit-form" @submit.prevent="submitEdit"> <!-- Edit form -->
<div class="field"> <section class="detail-section">
<label>Name</label> <div class="section-label">Member details</div>
<input v-model="form.name" type="text" required /> <form class="edit-form" @submit.prevent="submitEdit">
</div> <div class="field">
<div class="field"> <label>Name</label>
<label>Email</label> <input v-model="form.name" type="text" required />
<input v-model="form.email" type="email" required /> </div>
</div> <div class="field">
<div class="field"> <label>Email</label>
<label>Circle</label> <input v-model="form.email" type="email" required />
<select v-model="form.circle"> </div>
<option value="community">Community</option> <div class="field">
<option value="founder">Founder</option> <label>Circle</label>
<option value="practitioner">Practitioner</option> <select v-model="form.circle">
</select> <option value="community">Community</option>
</div> <option value="founder">Founder</option>
<div class="field"> <option value="practitioner">Practitioner</option>
<label>Contribution tier ($/mo)</label> </select>
<select v-model="form.contributionTier"> </div>
<option value="0">$0</option> <div class="field">
<option value="5">$5</option> <label>Contribution tier ($/mo)</label>
<option value="15">$15</option> <select v-model="form.contributionTier">
<option value="30">$30</option> <option value="0">$0</option>
<option value="50">$50</option> <option value="5">$5</option>
</select> <option value="15">$15</option>
</div> <option value="30">$30</option>
<div class="field"> <option value="50">$50</option>
<label>Status</label> </select>
<select v-model="form.status"> </div>
<option value="pending_payment">pending_payment</option> <div class="field">
<option value="active">active</option> <label>Status</label>
<option value="suspended">suspended</option> <select v-model="form.status">
<option value="cancelled">cancelled</option> <option value="pending_payment">pending_payment</option>
</select> <option value="active">active</option>
</div> <option value="suspended">suspended</option>
<div class="field"> <option value="cancelled">cancelled</option>
<label>Role</label> </select>
<select v-model="form.role"> </div>
<option value="member">member</option> <div class="field">
<option value="admin">admin</option> <label>Role</label>
</select> <select v-model="form.role">
</div> <option value="member">member</option>
<div class="form-actions"> <option value="admin">admin</option>
<button type="submit" class="btn btn-primary" :disabled="saving"> </select>
{{ saving ? "Saving…" : "Save changes" }} </div>
</button> <div class="form-actions">
<button type="button" class="btn" @click="resetForm">Reset</button> <button type="submit" class="btn btn-primary" :disabled="saving">
</div> {{ saving ? "Saving…" : "Save changes" }}
</form> </button>
</section> <button type="button" class="btn" @click="resetForm">Reset</button>
</div>
</form>
</section>
<!-- Metadata --> <!-- Metadata -->
<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 class="meta-row"> <div v-if="member.memberNumber" class="meta-row">
<dt>Member ID</dt> <dt>Member number</dt>
<dd class="mono">{{ member._id }}</dd> <dd class="mono">#{{ member.memberNumber }}</dd>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<dt>Joined</dt> <dt>Member ID</dt>
<dd>{{ formatDate(member.createdAt) }}</dd> <dd class="mono">{{ member._id }}</dd>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<dt>Invite email</dt> <dt>Joined</dt>
<dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'"> <dd>{{ formatDate(member.createdAt) }}</dd>
{{ member.inviteEmailSent ? "Sent" : "Not sent" }} </div>
</dd> <div class="meta-row">
</div> <dt>Invite email</dt>
<div class="meta-row"> <dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
<dt>Slack invite</dt> {{ member.inviteEmailSent ? "Sent" : "Not sent" }}
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'"> </dd>
{{ member.slackInvited ? "Invited" : "Pending" }} </div>
</dd> <div class="meta-row">
</div> <dt>Slack invite</dt>
<div v-if="member.helcimCustomerId" class="meta-row"> <dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
<dt>Helcim customer</dt> {{ member.slackInvited ? "Invited" : "Pending" }}
<dd class="mono">{{ member.helcimCustomerId }}</dd> </dd>
</div> </div>
<div v-if="member.helcimSubscriptionId" class="meta-row"> <div v-if="member.helcimCustomerId" class="meta-row">
<dt>Helcim subscription</dt> <dt>Helcim customer</dt>
<dd class="mono">{{ member.helcimSubscriptionId }}</dd> <dd class="mono">{{ member.helcimCustomerId }}</dd>
</div> </div>
</dl> <div v-if="member.helcimSubscriptionId" class="meta-row">
</section> <dt>Helcim subscription</dt>
<dd class="mono">{{ member.helcimSubscriptionId }}</dd>
</div>
</dl>
</section>
<!-- Notification preferences --> <!-- Notification preferences -->
<section class="detail-section"> <section class="detail-section">
<div class="section-label">Notification preferences</div> <div class="section-label">Notification preferences</div>
<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 ? "On" : "Off" }}
member.notifications?.events !== false </dd>
? 'status-ok' </div>
: 'status-dim' <div class="meta-row">
" <dt>Community updates</dt>
> <dd :class="member.notifications?.updates !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.events !== false ? "On" : "Off" }} {{ member.notifications?.updates !== false ? "On" : "Off" }}
</dd> </dd>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<dt>Community updates</dt> <dt>Peer support requests</dt>
<dd <dd :class="member.notifications?.peerRequests !== false ? 'status-ok' : 'status-dim'">
:class=" {{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
member.notifications?.updates !== false </dd>
? 'status-ok' </div>
: 'status-dim' </dl>
" </section>
> </div>
{{ 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'
"
>
{{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
</dd>
</div>
</dl>
</section>
<!-- 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">
<ClientOnly> <div class="activity-panel-header">
<div v-if="activityLoading && !activityEntries.length" class="loading-state"> <div class="section-label">Activity log</div>
<div class="spinner" /> <span class="activity-legend">
Loading activity... <span class="al-vis-badge">admin-only</span> = not visible to member
</div> </span>
<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' }">
<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>
<div v-if="activityHasMore" class="al-load-more">
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
{{ activityLoadingMore ? 'Loading...' : 'Load More' }}
</button>
</div> </div>
<ClientOnly>
<div v-if="activityLoading && !activityEntries.length" class="activity-loading">
<div class="spinner" />
Loading activity...
</div>
<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 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' }}
</button>
</div>
</div>
<div v-else class="activity-empty">
No activity recorded.
</div>
</ClientOnly>
</div> </div>
<div v-else class="loading-state"> </div>
No activity recorded. </div>
</div>
</ClientOnly>
</section>
</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 {

View file

@ -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' <span class="check-mark" />
: false </label>
"
@update:model-value="toggleSelectAll"
/>
</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;
} }

View file

@ -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' <span class="check-mark" />
: false </label>
"
@update:model-value="toggleSelectAll"
/>
</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 v-for="pr in filtered"
:class="{ 'row-expanded': expandedId === pr._id }" :key="pr._id"
@click="toggleExpand(pr._id)" class="selectable-row"
style="cursor: pointer" :class="{ 'row-selected': selectedIds.includes(pr._id) }"
> @click="toggleSelect(pr._id)"
<td class="col-check" @click.stop> >
<UCheckbox <td class="col-check" @click.stop>
:label="`Select ${pr.name || pr.email}`" <label class="custom-check" :aria-label="`Select ${pr.name || pr.email}`">
:ui="{ label: 'sr-only' }" <input
:model-value="selectedIds.includes(pr._id)" type="checkbox"
@update:model-value="toggleSelect(pr._id)" :checked="selectedIds.includes(pr._id)"
@change="toggleSelect(pr._id)"
/> />
</td> <span class="check-mark" />
<td class="col-name">{{ pr.name || "—" }}</td> </label>
<td class="col-email">{{ pr.email }}</td> </td>
<td>{{ pr.city || "—" }}</td> <td class="col-name">{{ pr.name || "—" }}</td>
<td>{{ pr.role || "—" }}</td> <td class="col-email">{{ pr.email }}</td>
<td> <td>{{ pr.city || "—" }}</td>
<span class="status-badge" :class="`status-${pr.status}`"> <td>{{ pr.role || "—" }}</td>
{{ pr.status }} <td @click.stop>
</span> <select
</td> class="inline-status"
<td class="col-mono col-date"> :class="`status-${pr.status}`"
{{ formatDate(pr.createdAt) }} :value="pr.status"
</td> :disabled="savingId === pr._id"
</tr> aria-label="Change status"
@change="updateStatus(pr._id, $event.target.value)"
<!-- Expanded detail row --> >
<tr v-if="expandedId === pr._id" class="detail-row"> <option value="pending">Pending</option>
<td colspan="7"> <option value="selected">Selected</option>
<div class="detail-panel"> <option value="invited">Invited</option>
<div class="detail-fields"> <option value="accepted">Accepted</option>
<div class="field"> <option value="expired">Expired</option>
<label>Admin Notes</label> </select>
<textarea </td>
v-model="editNotes" <td class="col-mono col-date">
rows="3" {{ formatDate(pr.createdAt) }}
placeholder="Add notes about this pre-registrant..." </td>
@click.stop </tr>
></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>

View file

@ -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,

View file

@ -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; }

View file

@ -13,209 +13,209 @@
</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-avatar"> <div class="profile-hero" :class="{ 'profile-hero--with-links': hasSocialLinks }">
<img
v-if="member.avatar" <!-- Left: Avatar + Identity -->
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`" <div class="profile-hero-left">
:alt="member.name" <div class="profile-avatar">
class="profile-avatar-img" <img
/> v-if="member.avatar"
<span v-else class="profile-initials">{{ :src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
getInitials(member.name) :alt="member.name"
}}</span> class="profile-avatar-img"
</div> />
<div class="profile-identity"> <span v-else class="profile-initials">{{ getInitials(member.name) }}</span>
<h1 class="profile-name"> </div>
{{ member.name }} <div class="profile-identity">
<span v-if="member.pronouns" class="profile-pronouns">{{ <h1 class="profile-name">
member.pronouns {{ member.name }}<span v-if="member.memberNumber" class="profile-member-number">#{{ member.memberNumber }}</span>
}}</span> </h1>
</h1> <div v-if="member.pronouns" class="profile-pronouns-row">
<div class="profile-meta"> <span class="profile-pronouns">{{ member.pronouns }}</span>
<span v-if="member.circle" class="badge" :class="member.circle">{{ </div>
circleLabels[member.circle] <div class="profile-meta">
}}</span> <span v-if="member.circle" class="badge" :class="member.circle">
<template v-if="member.studio"> {{ circleLabels[member.circle] }}
<span class="meta-sep">&middot;</span> </span>
<span class="profile-studio">{{ member.studio }}</span> <template v-if="member.studio">
</template> <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>
</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">About</div> <div class="section-label">Links</div>
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div> <div class="social-links">
</div> <a
v-if="member.socialLinks.website"
<!-- Location & Timezone --> :href="member.socialLinks.website"
<div v-if="member.location || member.timeZone" class="profile-section"> target="_blank"
<div class="section-label">Location</div> rel="noopener noreferrer"
<p class="profile-detail"> class="social-link"
{{ [member.location, member.timeZone].filter(Boolean).join(" · ") }} >Website</a>
</p> <a
</div> v-if="member.socialLinks.itch"
:href="member.socialLinks.itch"
<!-- What I Do (craft tags, falling back to offering) --> target="_blank"
<div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section"> rel="noopener noreferrer"
<div class="section-label">What I Do</div> class="social-link"
<div v-if="craftTagsDisplay.length > 0" class="tag-list"> >itch.io</a>
<span <a
v-for="tag in craftTagsDisplay" v-if="member.socialLinks.mastodon"
:key="tag" :href="member.socialLinks.mastodon"
class="tag-pill" target="_blank"
>{{ tagLabel('craft', tag) }}</span rel="noopener noreferrer"
> class="social-link"
</div> >Mastodon</a>
<p v-if="member.offering?.text" class="profile-detail offering-text"> <a
{{ member.offering.text }} v-if="member.socialLinks.bluesky"
</p> :href="member.socialLinks.bluesky"
</div> target="_blank"
rel="noopener noreferrer"
<!-- Community Connections (cooperative topics with states, falling back to lookingFor) --> class="social-link"
<div >Bluesky</a>
v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details" <a
class="profile-section" v-if="member.socialLinks.linkedin"
> :href="member.socialLinks.linkedin"
<div class="section-label">Community Connections</div> target="_blank"
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list"> rel="noopener noreferrer"
<span class="social-link"
v-for="topic in connectionTopicsDisplay" >LinkedIn</a>
:key="topic.tagSlug || topic"
class="tag-pill connection-pill"
>
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ tagLabel('cooperative', topic.tagSlug || topic) }}
</span>
</div>
<p v-if="member.communityConnections?.details" class="profile-detail connection-details">
{{ member.communityConnections.details }}
</p>
<p v-else-if="member.lookingFor?.text" class="profile-detail looking-text">
{{ 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) -->
<div v-if="showPeerSupport" class="profile-section">
<div class="section-label">Peer Support</div>
<div v-if="member.peerSupport?.skillTopics?.length" class="peer-group">
<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
>
</div> </div>
</div> </div>
<div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
<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
>
</div>
</div>
<p v-if="peerAvailability" class="profile-detail">
{{ peerAvailability }}
</p>
</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">
<UIcon :name="getActivity(entry).icon" class="activity-icon" />
<span class="activity-text">{{ getActivity(entry).text }}</span>
<span class="activity-time">{{ formatRelativeDate(entry.timestamp) }}</span>
</div>
</div>
</div> </div>
<!-- END HERO -->
<!-- Auth Notice --> <!-- SidebarLayout wraps all remaining sections -->
<div v-if="!isAuthenticated" class="auth-notice"> <SidebarLayout>
<p>Sign in to see full profile details</p>
<button <!-- Bio: parch (inverted) block -->
type="button" <div v-if="member.bio" class="profile-section profile-section--parch">
class="btn" <div class="section-label">About</div>
@click=" <div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
openLoginModal({ </div>
title: 'Sign in to see more',
description: 'Log in to view full member profiles', <!-- 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"
> >
Log In <!-- Left: What I Do -->
</button> <div class="profile-section">
</div> <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>
</div>
<p v-if="member.offering?.text" class="profile-detail offering-text">
{{ member.offering.text }}
</p>
</div>
<!-- Back Link --> <!-- Right: Community Connections -->
<div class="profile-back"> <div class="profile-section">
<NuxtLink to="/members" class="back-link"> Back to Members</NuxtLink> <div class="section-label">Community Connections</div>
</div> <div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
</div> <span
v-for="topic in connectionTopicsDisplay"
:key="topic.tagSlug || topic"
class="tag-pill connection-pill"
>
<span v-if="topic.state" class="connection-state">{{ stateLabel(topic.state) }}</span>
{{ tagLabel('cooperative', topic.tagSlug || topic) }}
</span>
</div>
<p v-if="member.communityConnections?.details" class="profile-detail connection-details">
{{ member.communityConnections.details }}
</p>
<p v-else-if="member.lookingFor?.text" class="profile-detail looking-text">
{{ member.lookingFor.text }}
</p>
</div>
</div>
<!-- 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>
<div class="tag-list">
<span
v-for="topic in member.peerSupport.skillTopics"
:key="topic"
class="tag-pill"
>{{ topic }}</span>
</div>
</div>
<div v-if="member.peerSupport?.supportTopics?.length" class="peer-group">
<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>
</div>
</div>
<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-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="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' })"
>
Log In
</button>
</div>
</div>
<!-- Back Link -->
<div class="profile-back">
<NuxtLink to="/members" class="back-link"> Back to Members</NuxtLink>
</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>

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 }) => { 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 });
}); });
}); });

View file

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

View file

@ -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()

View file

@ -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",

View file

@ -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",

View file

@ -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) {

View file

@ -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,

View file

@ -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: {

View file

@ -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

View file

@ -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
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 // 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,
}); });

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', () => ({ 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'

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