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

@ -50,11 +50,11 @@
--candle: #d4a03a;
--candle-dim: #b8922e;
--candle-faint: #8a7030;
--ember: #c06030;
--ember: #ca6a3a;
--text: #a89880;
--text-bright: #d0c8b0;
--text-dim: #8a7e6a;
--text-faint: #5a5040;
--text-dim: #958774;
--text-faint: #8b7b62;
--parch: #ede4d0;
--parch-hover: #d4c8a8;
--parch-text: #2a2015;
@ -62,7 +62,9 @@
--c-community: #a06850;
--c-founder: #c06030;
--c-practitioner: #4a7080;
--ember-bg: rgba(192, 96, 48, 0.14);
--green: #6e9c52;
--green-bg: rgba(110, 156, 82, 0.12);
--ember-bg: rgba(202, 106, 58, 0.14);
}
/* ---- TAILWIND @THEME MAPPING ---- */

View file

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

View file

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

View file

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

View file

@ -27,14 +27,6 @@
<option value="showcase">Showcase</option>
</select>
</div>
<div class="field" style="margin-bottom: 0;">
<select v-model="statusFilter">
<option value="all">All Status</option>
<option value="upcoming">Upcoming</option>
<option value="ongoing">Ongoing</option>
<option value="past">Past</option>
</select>
</div>
<div class="field" style="margin-bottom: 0;">
<select v-model="seriesFilter">
<option value="all">All Events</option>
@ -44,124 +36,214 @@
</div>
</div>
<!-- Events Table -->
<div class="table-wrap">
<div v-if="pending" class="loading-state">
<div class="spinner" />
<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>
<!-- Loading / Error -->
<div v-if="pending" class="loading-state">
<div class="spinner" />
<span>Loading events...</span>
</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 -->
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
<div class="modal">
@ -199,33 +281,12 @@ const {
const searchQuery = ref('')
const typeFilter = ref('all')
const statusFilter = ref('all')
const seriesFilter = ref('all')
const filteredEvents = computed(() => {
if (!events.value) return []
return events.value.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesType =
typeFilter.value === 'all' || event.eventType === typeFilter.value
const eventStatus = getEventStatus(event)
const matchesStatus =
statusFilter.value === 'all' || eventStatus.toLowerCase() === statusFilter.value
const matchesSeries =
seriesFilter.value === 'all' ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesStatus && matchesSeries
})
})
const upcomingPage = ref(1)
const pastPage = ref(1)
const UPCOMING_PAGE_SIZE = 10
const PAST_PAGE_SIZE = 5
const getEventStatus = (event) => {
const now = new Date()
@ -237,6 +298,57 @@ const getEventStatus = (event) => {
return 'Past'
}
const applyBaseFilters = (list) => {
if (!list) return []
return list.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesType =
typeFilter.value === 'all' || event.eventType === typeFilter.value
const matchesSeries =
seriesFilter.value === 'all' ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesSeries
})
}
const upcomingFiltered = computed(() => {
return applyBaseFilters(events.value)
.filter((e) => getEventStatus(e) !== 'Past')
.sort((a, b) => new Date(a.startDate) - new Date(b.startDate))
})
const pastFiltered = computed(() => {
return applyBaseFilters(events.value)
.filter((e) => getEventStatus(e) === 'Past')
.sort((a, b) => new Date(b.startDate) - new Date(a.startDate))
})
const upcomingPageCount = computed(() => Math.max(1, Math.ceil(upcomingFiltered.value.length / UPCOMING_PAGE_SIZE)))
const pastPageCount = computed(() => Math.max(1, Math.ceil(pastFiltered.value.length / PAST_PAGE_SIZE)))
const upcomingPaged = computed(() => {
const start = (upcomingPage.value - 1) * UPCOMING_PAGE_SIZE
return upcomingFiltered.value.slice(start, start + UPCOMING_PAGE_SIZE)
})
const pastPaged = computed(() => {
const start = (pastPage.value - 1) * PAST_PAGE_SIZE
return pastFiltered.value.slice(start, start + PAST_PAGE_SIZE)
})
// Reset pagination when filters change
watch([searchQuery, typeFilter, seriesFilter], () => {
upcomingPage.value = 1
pastPage.value = 1
})
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
@ -309,10 +421,7 @@ const editEvent = (event) => {
</script>
<style scoped>
.admin-events {
max-width: 1100px;
margin: 0 auto;
}
.admin-events {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -350,9 +459,34 @@ const editEvent = (event) => {
flex-wrap: wrap;
}
/* ---- SECTION DIVIDER ---- */
.section-divider {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 28px 0;
}
.section-divider .section-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: normal;
}
.event-count {
font-size: 10px;
color: var(--text-faint);
background: var(--surface);
border: 1px dashed var(--border);
padding: 1px 7px;
letter-spacing: 0.04em;
}
/* ---- TABLE ---- */
.table-wrap {
padding: 0 28px 28px;
padding: 12px 28px 24px;
}
table {
@ -580,6 +714,41 @@ tbody td {
color: var(--ember);
}
/* ---- PAGINATION ---- */
.pagination {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0 0;
}
.page-btn {
background: none;
border: 1px dashed var(--border);
color: var(--candle);
cursor: pointer;
font-family: 'Commit Mono', monospace;
font-size: 12px;
padding: 3px 10px;
transition: border-color 0.1s;
}
.page-btn:disabled {
color: var(--text-faint);
border-color: var(--border);
cursor: default;
}
.page-btn:not(:disabled):hover {
border-color: var(--candle);
}
.page-info {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.04em;
}
/* ---- STATES ---- */
.loading-state {
text-align: center;
@ -597,7 +766,7 @@ tbody td {
.empty-state {
text-align: center;
padding: 48px 24px;
padding: 32px 24px;
color: var(--text-faint);
font-size: 12px;
}
@ -699,11 +868,15 @@ tbody td {
.filter-bar {
flex-direction: column;
padding: 12px 20px;
padding: 16px 20px;
}
.section-divider {
padding: 16px 20px 0;
}
.table-wrap {
padding: 0 12px 20px;
padding: 12px 20px 20px;
overflow-x: auto;
}

View file

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

View file

@ -1,19 +1,23 @@
<template>
<div class="admin-member-detail">
<!-- Page Header -->
<div class="page-header">
<div class="header-nav">
<NuxtLink to="/admin/members" class="back-link"> Members</NuxtLink>
<NuxtLink v-if="member && member.status === 'active' && member.showInDirectory" :to="`/members/${member._id}`" class="profile-link" target="_blank">
View public profile
</NuxtLink>
</div>
<div class="header-row">
<div>
<NuxtLink to="/admin/members" class="back-link"> Members</NuxtLink>
<h1 v-if="member">{{ member.name }}</h1>
<h1 v-else-if="pending">Loading</h1>
<h1 v-else>Member not found</h1>
<p v-if="member" class="member-email">{{ member.email }}</p>
</div>
<div v-if="member" class="header-actions">
<div v-if="member" class="header-badges">
<span class="badge" :class="member.circle">{{ member.circle }}</span>
<span :class="statusClass(member.status)" class="status-badge">{{
member.status
}}</span>
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div>
</div>
</div>
@ -26,165 +30,173 @@
<div v-else-if="fetchError" class="error-state">Failed to load member.</div>
<template v-else-if="member">
<!-- Edit form -->
<section class="detail-section">
<div class="section-label">Member details</div>
<form class="edit-form" @submit.prevent="submitEdit">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="form.email" type="email" required />
</div>
<div class="field">
<label>Circle</label>
<select v-model="form.circle">
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<div class="field">
<label>Contribution tier ($/mo)</label>
<select v-model="form.contributionTier">
<option value="0">$0</option>
<option value="5">$5</option>
<option value="15">$15</option>
<option value="30">$30</option>
<option value="50">$50</option>
</select>
</div>
<div class="field">
<label>Status</label>
<select v-model="form.status">
<option value="pending_payment">pending_payment</option>
<option value="active">active</option>
<option value="suspended">suspended</option>
<option value="cancelled">cancelled</option>
</select>
</div>
<div class="field">
<label>Role</label>
<select v-model="form.role">
<option value="member">member</option>
<option value="admin">admin</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? "Saving…" : "Save changes" }}
</button>
<button type="button" class="btn" @click="resetForm">Reset</button>
</div>
</form>
</section>
<div class="detail-body">
<!-- LEFT COLUMN: form + metadata -->
<div class="detail-left">
<!-- Edit form -->
<section class="detail-section">
<div class="section-label">Member details</div>
<form class="edit-form" @submit.prevent="submitEdit">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="form.email" type="email" required />
</div>
<div class="field">
<label>Circle</label>
<select v-model="form.circle">
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<div class="field">
<label>Contribution tier ($/mo)</label>
<select v-model="form.contributionTier">
<option value="0">$0</option>
<option value="5">$5</option>
<option value="15">$15</option>
<option value="30">$30</option>
<option value="50">$50</option>
</select>
</div>
<div class="field">
<label>Status</label>
<select v-model="form.status">
<option value="pending_payment">pending_payment</option>
<option value="active">active</option>
<option value="suspended">suspended</option>
<option value="cancelled">cancelled</option>
</select>
</div>
<div class="field">
<label>Role</label>
<select v-model="form.role">
<option value="member">member</option>
<option value="admin">admin</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? "Saving…" : "Save changes" }}
</button>
<button type="button" class="btn" @click="resetForm">Reset</button>
</div>
</form>
</section>
<!-- Metadata -->
<section class="detail-section">
<div class="section-label">Account info</div>
<dl class="meta-list">
<div class="meta-row">
<dt>Member ID</dt>
<dd class="mono">{{ member._id }}</dd>
</div>
<div class="meta-row">
<dt>Joined</dt>
<dd>{{ formatDate(member.createdAt) }}</dd>
</div>
<div class="meta-row">
<dt>Invite email</dt>
<dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
{{ member.inviteEmailSent ? "Sent" : "Not sent" }}
</dd>
</div>
<div class="meta-row">
<dt>Slack invite</dt>
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
{{ member.slackInvited ? "Invited" : "Pending" }}
</dd>
</div>
<div v-if="member.helcimCustomerId" class="meta-row">
<dt>Helcim customer</dt>
<dd class="mono">{{ member.helcimCustomerId }}</dd>
</div>
<div v-if="member.helcimSubscriptionId" class="meta-row">
<dt>Helcim subscription</dt>
<dd class="mono">{{ member.helcimSubscriptionId }}</dd>
</div>
</dl>
</section>
<!-- Metadata -->
<section class="detail-section">
<div class="section-label">Account info</div>
<dl class="meta-list">
<div v-if="member.memberNumber" class="meta-row">
<dt>Member number</dt>
<dd class="mono">#{{ member.memberNumber }}</dd>
</div>
<div class="meta-row">
<dt>Member ID</dt>
<dd class="mono">{{ member._id }}</dd>
</div>
<div class="meta-row">
<dt>Joined</dt>
<dd>{{ formatDate(member.createdAt) }}</dd>
</div>
<div class="meta-row">
<dt>Invite email</dt>
<dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
{{ member.inviteEmailSent ? "Sent" : "Not sent" }}
</dd>
</div>
<div class="meta-row">
<dt>Slack invite</dt>
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
{{ member.slackInvited ? "Invited" : "Pending" }}
</dd>
</div>
<div v-if="member.helcimCustomerId" class="meta-row">
<dt>Helcim customer</dt>
<dd class="mono">{{ member.helcimCustomerId }}</dd>
</div>
<div v-if="member.helcimSubscriptionId" class="meta-row">
<dt>Helcim subscription</dt>
<dd class="mono">{{ member.helcimSubscriptionId }}</dd>
</div>
</dl>
</section>
<!-- Notification preferences -->
<section class="detail-section">
<div class="section-label">Notification preferences</div>
<dl class="meta-list">
<div class="meta-row">
<dt>Event reminders</dt>
<dd
:class="
member.notifications?.events !== false
? 'status-ok'
: 'status-dim'
"
>
{{ member.notifications?.events !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Community updates</dt>
<dd
:class="
member.notifications?.updates !== false
? 'status-ok'
: 'status-dim'
"
>
{{ 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>
<!-- Notification preferences -->
<section class="detail-section">
<div class="section-label">Notification preferences</div>
<dl class="meta-list">
<div class="meta-row">
<dt>Event reminders</dt>
<dd :class="member.notifications?.events !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.events !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Community updates</dt>
<dd :class="member.notifications?.updates !== false ? 'status-ok' : 'status-dim'">
{{ 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>
</div>
<!-- Full Activity Log -->
<section class="detail-section">
<div class="section-label">Full Activity Log</div>
<ClientOnly>
<div v-if="activityLoading && !activityEntries.length" class="loading-state">
<div class="spinner" />
Loading activity...
</div>
<div v-else-if="activityEntries.length" class="activity-log">
<div v-for="entry in activityEntries" :key="entry._id" class="al-item" :class="{ 'al-admin': entry.visibility === 'admin' }">
<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>
<!-- RIGHT COLUMN: activity log -->
<div class="detail-right">
<div class="activity-panel">
<div class="activity-panel-header">
<div class="section-label">Activity log</div>
<span class="activity-legend">
<span class="al-vis-badge">admin-only</span> = not visible to member
</span>
</div>
<ClientOnly>
<div v-if="activityLoading && !activityEntries.length" class="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 v-else class="loading-state">
No activity recorded.
</div>
</ClientOnly>
</section>
</div>
</div>
</template>
</div>
</template>
@ -271,12 +283,12 @@ async function submitEdit() {
member.value = { ...member.value, ...updated, role: form.role };
pageBreadcrumbTitle.value = form.name;
}
toast.add({ title: "Member updated", color: "green" });
toast.add({ title: "Member updated", color: "success" });
} catch (err) {
toast.add({
title: "Failed to update member",
description: err.data?.statusMessage || err.message,
color: "red",
color: "error",
});
} finally {
saving.value = false;
@ -344,13 +356,42 @@ onMounted(loadActivity)
</script>
<style scoped>
.admin-member-detail {
max-width: 640px;
padding: 32px 40px 60px;
.admin-member-detail {}
/* ---- PAGE HEADER ---- */
.page-header {
padding: 28px 28px 20px;
border-bottom: 1px dashed var(--border);
}
.page-header {
margin-bottom: 32px;
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.back-link {
font-size: 11px;
color: var(--text-faint);
text-decoration: none;
letter-spacing: 0.02em;
}
.back-link:hover {
color: var(--candle);
text-decoration: none;
}
.profile-link {
font-size: 11px;
color: var(--candle);
text-decoration: none;
letter-spacing: 0.02em;
}
.profile-link:hover {
text-decoration: underline;
}
.header-row {
@ -360,26 +401,13 @@ onMounted(loadActivity)
gap: 16px;
}
.back-link {
display: inline-block;
font-size: 11px;
color: var(--text-faint);
text-decoration: none;
margin-bottom: 8px;
letter-spacing: 0.02em;
}
.back-link:hover {
color: var(--candle);
}
.page-header h1 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 600;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 4px;
line-height: 1.2;
}
.member-email {
@ -388,29 +416,44 @@ onMounted(loadActivity)
margin: 0;
}
.header-actions {
.header-badges {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding-top: 6px;
padding-top: 4px;
}
.status-badge {
font-size: 11px;
font-size: 10px;
font-family: "Commit Mono", monospace;
padding: 2px 8px;
border: 1px dashed var(--border);
color: var(--text-dim);
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* ---- TWO-COLUMN BODY ---- */
.detail-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.detail-left {
border-right: 1px dashed var(--border);
}
.detail-section {
margin-bottom: 40px;
padding: 24px 28px;
border-bottom: 1px dashed var(--border);
}
.edit-form {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
margin-top: 12px;
}
@ -418,6 +461,8 @@ onMounted(loadActivity)
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px dashed var(--border);
}
.meta-list {
@ -430,8 +475,8 @@ onMounted(loadActivity)
.meta-row {
display: flex;
gap: 24px;
padding: 10px 14px;
gap: 16px;
padding: 9px 14px;
border-bottom: 1px dashed var(--border);
}
@ -452,6 +497,7 @@ onMounted(loadActivity)
font-size: 12px;
color: var(--text);
margin: 0;
word-break: break-all;
}
.mono {
@ -459,8 +505,9 @@ onMounted(loadActivity)
font-size: 11px;
}
/* ---- STATUS ---- */
.status-ok {
color: var(--c-founder);
color: var(--green);
}
.status-dim {
@ -471,6 +518,7 @@ onMounted(loadActivity)
color: var(--ember);
}
/* ---- STATES ---- */
.loading-state,
.error-state {
display: flex;
@ -478,46 +526,122 @@ onMounted(loadActivity)
gap: 12px;
color: var(--text-dim);
font-size: 13px;
padding: 40px 0;
padding: 40px 28px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--candle);
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
to { transform: rotate(360deg); }
}
/* ---- ACTIVITY LOG ---- */
.activity-log {
margin-top: 12px;
border: 1px dashed var(--border);
/* ---- ACTIVITY PANEL ---- */
.detail-right {
position: relative;
}
.al-item {
.activity-panel {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 120px);
}
.activity-panel-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
justify-content: space-between;
padding: 24px 28px 16px;
border-bottom: 1px dashed var(--border);
flex-shrink: 0;
}
.activity-legend {
font-size: 10px;
color: var(--text-faint);
display: flex;
align-items: center;
gap: 6px;
}
.activity-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 32px 28px;
color: var(--text-faint);
font-size: 12px;
}
.al-item:last-child {
border-bottom: none;
.activity-empty {
padding: 32px 28px;
color: var(--text-faint);
font-size: 12px;
}
.al-admin {
opacity: 0.7;
/* Timeline */
.activity-timeline {
overflow-y: auto;
flex: 1;
padding: 16px 0 24px;
}
.al-item {
display: grid;
grid-template-columns: 20px 1fr;
gap: 0 10px;
padding: 0 28px 0 20px;
margin-bottom: 16px;
position: relative;
}
.al-item::before {
content: '';
position: absolute;
left: 27px;
top: 18px;
bottom: -16px;
width: 1px;
border-left: 1px dashed var(--border);
}
.al-item:last-child::before {
display: none;
}
.al-dot {
width: 6px;
height: 6px;
border: 1px dashed var(--border);
background: var(--bg);
flex-shrink: 0;
margin-top: 4px;
align-self: start;
}
.al-admin .al-dot {
border-color: var(--candle-faint);
background: var(--surface);
}
.al-body {
min-width: 0;
}
.al-row {
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.al-icon {
@ -525,41 +649,75 @@ onMounted(loadActivity)
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
margin-top: 1px;
}
.al-text {
flex: 1;
min-width: 0;
color: var(--text-dim);
color: var(--text);
font-size: 12px;
line-height: 1.4;
}
.al-time {
display: block;
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
font-size: 10px;
margin-top: 3px;
letter-spacing: 0.02em;
}
.al-vis-badge {
font-size: 9px;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 4px;
color: var(--candle);
border: 1px dashed var(--candle-faint);
padding: 1px 5px;
flex-shrink: 0;
letter-spacing: 0.04em;
}
.al-load-more {
display: flex;
justify-content: center;
padding: 12px;
padding: 8px 28px 0;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.detail-body {
grid-template-columns: 1fr;
}
.detail-left {
border-right: none;
}
.activity-panel {
position: static;
max-height: none;
border-top: 1px dashed var(--border);
}
}
@media (max-width: 768px) {
.admin-member-detail {
padding: 20px 16px 40px;
.page-header {
padding: 24px 20px 16px;
}
.header-row {
.detail-section {
padding: 20px;
}
.activity-panel-header {
padding: 16px 20px 12px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.al-item {
padding: 0 20px 0 14px;
}
.meta-row {

View file

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

View file

@ -65,103 +65,66 @@
<thead>
<tr>
<th class="col-check">
<UCheckbox
label="Select all"
:ui="{ label: 'sr-only' }"
:model-value="
allVisibleSelected
? true
: someVisibleSelected
? 'indeterminate'
: false
"
@update:model-value="toggleSelectAll"
/>
<label class="custom-check" aria-label="Select all">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
<span class="check-mark" />
</label>
</th>
<th>Name</th>
<th>Email</th>
<th>City</th>
<th>Role</th>
<th>Status</th>
<th class="col-date">Registered</th>
<th class="sortable" @click="toggleSort('name')">Name <span v-if="sortKey === 'name'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('email')">Email <span v-if="sortKey === 'email'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('city')">City <span v-if="sortKey === 'city'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('role')">Role <span v-if="sortKey === 'role'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('status')">Status <span v-if="sortKey === 'status'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable col-date" @click="toggleSort('createdAt')">Registered <span v-if="sortKey === 'createdAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
</tr>
</thead>
<tbody>
<template v-for="pr in filtered" :key="pr._id">
<tr
:class="{ 'row-expanded': expandedId === pr._id }"
@click="toggleExpand(pr._id)"
style="cursor: pointer"
>
<td class="col-check" @click.stop>
<UCheckbox
:label="`Select ${pr.name || pr.email}`"
:ui="{ label: 'sr-only' }"
:model-value="selectedIds.includes(pr._id)"
@update:model-value="toggleSelect(pr._id)"
<tr
v-for="pr in filtered"
:key="pr._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(pr._id) }"
@click="toggleSelect(pr._id)"
>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${pr.name || pr.email}`">
<input
type="checkbox"
:checked="selectedIds.includes(pr._id)"
@change="toggleSelect(pr._id)"
/>
</td>
<td class="col-name">{{ pr.name || "—" }}</td>
<td class="col-email">{{ pr.email }}</td>
<td>{{ pr.city || "—" }}</td>
<td>{{ pr.role || "—" }}</td>
<td>
<span class="status-badge" :class="`status-${pr.status}`">
{{ pr.status }}
</span>
</td>
<td class="col-mono col-date">
{{ formatDate(pr.createdAt) }}
</td>
</tr>
<!-- Expanded detail row -->
<tr v-if="expandedId === pr._id" class="detail-row">
<td colspan="7">
<div class="detail-panel">
<div class="detail-fields">
<div class="field">
<label>Admin Notes</label>
<textarea
v-model="editNotes"
rows="3"
placeholder="Add notes about this pre-registrant..."
@click.stop
></textarea>
</div>
<div class="detail-actions">
<select
v-model="editStatus"
aria-label="Change status"
@click.stop
>
<option value="pending">Pending</option>
<option value="selected">Selected</option>
</select>
<button
class="btn"
@click.stop="saveDetail(pr._id)"
:disabled="savingDetail"
>
{{ savingDetail ? "Saving..." : "Save" }}
</button>
</div>
</div>
<div
v-if="pr.inviteEmailSentAt"
class="detail-meta"
>
Invite sent:
{{ new Date(pr.inviteEmailSentAt).toLocaleString() }}
</div>
<div v-if="pr.acceptedAt" class="detail-meta">
Accepted:
{{ new Date(pr.acceptedAt).toLocaleString() }}
</div>
</div>
</td>
</tr>
</template>
<span class="check-mark" />
</label>
</td>
<td class="col-name">{{ pr.name || "—" }}</td>
<td class="col-email">{{ pr.email }}</td>
<td>{{ pr.city || "—" }}</td>
<td>{{ pr.role || "—" }}</td>
<td @click.stop>
<select
class="inline-status"
:class="`status-${pr.status}`"
:value="pr.status"
:disabled="savingId === pr._id"
aria-label="Change status"
@change="updateStatus(pr._id, $event.target.value)"
>
<option value="pending">Pending</option>
<option value="selected">Selected</option>
<option value="invited">Invited</option>
<option value="accepted">Accepted</option>
<option value="expired">Expired</option>
</select>
</td>
<td class="col-mono col-date">
{{ formatDate(pr.createdAt) }}
</td>
</tr>
</tbody>
</table>
@ -267,10 +230,9 @@ const { data: stats, refresh: refreshStats } = await useFetch(
const searchQuery = ref("");
const statusFilter = ref("");
const selectedIds = ref([]);
const expandedId = ref(null);
const editNotes = ref("");
const editStatus = ref("pending");
const savingDetail = ref(false);
const savingId = ref(null);
const sortKey = ref("");
const sortDir = ref("asc");
// Invite
const showInviteModal = ref(false);
@ -291,10 +253,19 @@ See you inside.`;
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
const toggleSort = (key) => {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = key;
sortDir.value = "asc";
}
};
const filtered = computed(() => {
if (!preRegistrants.value) return [];
return preRegistrants.value.filter((pr) => {
const result = preRegistrants.value.filter((pr) => {
const q = searchQuery.value.toLowerCase();
const matchesSearch =
!q ||
@ -307,6 +278,18 @@ const filtered = computed(() => {
return matchesSearch && matchesStatus;
});
if (sortKey.value) {
const dir = sortDir.value === "asc" ? 1 : -1;
const key = sortKey.value;
result.sort((a, b) => {
const aVal = (a[key] || "").toString().toLowerCase();
const bVal = (b[key] || "").toString().toLowerCase();
return aVal < bVal ? -dir : aVal > bVal ? dir : 0;
});
}
return result;
});
// Selection helpers
@ -319,12 +302,12 @@ const someVisibleSelected = computed(() => {
return filtered.value.some((pr) => selectedIds.value.includes(pr._id));
});
// IDs of selected pre-registrants that can actually be invited (pending or selected status)
// IDs of selected pre-registrants that can actually be invited (pending, selected, or invited for resend)
const invitableIds = computed(() => {
if (!preRegistrants.value) return [];
return selectedIds.value.filter((id) => {
const pr = preRegistrants.value.find((p) => p._id === id);
return pr && (pr.status === "pending" || pr.status === "selected");
return pr && (pr.status === "pending" || pr.status === "selected" || pr.status === "invited");
});
});
@ -350,28 +333,16 @@ const toggleSelect = (id) => {
}
};
// Expand / collapse detail rows
const toggleExpand = (id) => {
if (expandedId.value === id) {
expandedId.value = null;
return;
}
expandedId.value = id;
const pr = preRegistrants.value?.find((p) => p._id === id);
editNotes.value = pr?.adminNotes || "";
editStatus.value = pr?.status === "selected" ? "selected" : "pending";
};
const saveDetail = async (id) => {
savingDetail.value = true;
const updateStatus = async (id, newStatus) => {
savingId.value = id;
try {
await $fetch(`/api/admin/pre-registrants/${id}`, {
method: "PUT",
body: { status: editStatus.value, adminNotes: editNotes.value },
body: { status: newStatus },
});
await refresh();
await refreshStats();
toast.add({ title: "Updated", color: "green" });
toast.add({ title: "Status updated", color: "green" });
} catch (err) {
toast.add({
title: "Failed to update",
@ -379,7 +350,7 @@ const saveDetail = async (id) => {
color: "red",
});
} finally {
savingDetail.value = false;
savingId.value = null;
}
};
@ -459,10 +430,7 @@ const formatDate = (dateString) => {
</script>
<style scoped>
.admin-prereg {
max-width: 1100px;
margin: 0 auto;
}
.admin-prereg {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -525,6 +493,21 @@ thead th {
letter-spacing: 0.05em;
color: var(--text-faint);
border-bottom: 1px dashed var(--border);
font-weight: normal;
}
thead th.sortable {
cursor: pointer;
user-select: none;
}
thead th.sortable:hover {
color: var(--text-dim);
}
.sort-arrow {
font-size: 10px;
color: var(--candle);
}
tbody tr {
@ -543,11 +526,78 @@ tbody td {
}
.col-check {
width: 32px;
padding-left: 0;
width: 40px;
padding-left: 12px;
padding-right: 4px;
}
.selectable-row {
cursor: pointer;
}
.row-selected {
background: var(--surface);
}
/* ---- CUSTOM CHECKBOX ---- */
.custom-check {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
width: 16px;
height: 16px;
}
.custom-check input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.check-mark {
width: 14px;
height: 14px;
border: 1px solid var(--border);
background: var(--input-bg);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
}
.custom-check:hover .check-mark {
border-color: var(--candle);
}
.custom-check input:checked + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:checked + .check-mark::after {
content: "";
width: 4px;
height: 8px;
border: solid var(--bg);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg) translateY(-1px);
}
.custom-check input:indeterminate + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:indeterminate + .check-mark::after {
content: "";
width: 8px;
height: 0;
border-bottom: 1.5px solid var(--bg);
}
.col-name {
font-weight: 500;
color: var(--text-bright);
@ -600,42 +650,21 @@ tbody td {
border-color: var(--ember);
}
/* ---- EXPANDED DETAIL ROW ---- */
.row-expanded {
background: var(--surface);
/* ---- INLINE STATUS SELECT ---- */
.inline-status {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border: 1px dashed var(--border);
background: transparent;
cursor: pointer;
font-family: "Commit Mono", monospace;
}
.detail-row td {
padding: 0 10px 16px;
border-bottom: 1px dashed var(--border);
}
.detail-panel {
padding: 12px 0;
}
.detail-fields {
display: flex;
gap: 12px;
align-items: flex-end;
}
.detail-fields .field {
flex: 1;
margin-bottom: 0;
}
.detail-actions {
display: flex;
gap: 8px;
align-items: flex-end;
flex-shrink: 0;
}
.detail-meta {
font-size: 11px;
color: var(--text-faint);
margin-top: 8px;
.inline-status:disabled {
opacity: 0.5;
cursor: wait;
}
/* ---- STATUS INDICATORS ---- */
@ -822,9 +851,5 @@ tbody td {
table {
min-width: 600px;
}
.detail-fields {
flex-direction: column;
}
}
</style>

View file

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

View file

@ -251,7 +251,6 @@ const createAndAddEvent = async () => {
<style scoped>
.create-form {
max-width: 800px;
margin: 0 auto;
}
.page-header {
@ -271,13 +270,14 @@ const createAndAddEvent = async () => {
.page-header p { font-size: 12px; color: var(--text-dim); }
.back-link {
font-size: 12px;
color: var(--candle);
font-size: 11px;
color: var(--text-faint);
text-decoration: none;
margin-bottom: 8px;
display: inline-block;
letter-spacing: 0.02em;
}
.back-link:hover { text-decoration: underline; }
.back-link:hover { color: var(--candle); text-decoration: none; }
.form-body { padding: 24px 28px; }

View file

@ -13,209 +13,209 @@
</div>
<!-- Profile Content -->
<div v-else class="profile-content">
<!-- Header Area -->
<div class="profile-header">
<div class="profile-avatar">
<img
v-if="member.avatar"
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
:alt="member.name"
class="profile-avatar-img"
/>
<span v-else class="profile-initials">{{
getInitials(member.name)
}}</span>
</div>
<div class="profile-identity">
<h1 class="profile-name">
{{ member.name }}
<span v-if="member.pronouns" class="profile-pronouns">{{
member.pronouns
}}</span>
</h1>
<div class="profile-meta">
<span v-if="member.circle" class="badge" :class="member.circle">{{
circleLabels[member.circle]
}}</span>
<template v-if="member.studio">
<span class="meta-sep">&middot;</span>
<span class="profile-studio">{{ member.studio }}</span>
</template>
<template v-else>
<!-- HERO: full-bleed, outside SidebarLayout -->
<div class="profile-hero" :class="{ 'profile-hero--with-links': hasSocialLinks }">
<!-- Left: Avatar + Identity -->
<div class="profile-hero-left">
<div class="profile-avatar">
<img
v-if="member.avatar"
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
:alt="member.name"
class="profile-avatar-img"
/>
<span v-else class="profile-initials">{{ getInitials(member.name) }}</span>
</div>
<div class="profile-identity">
<h1 class="profile-name">
{{ member.name }}<span v-if="member.memberNumber" class="profile-member-number">#{{ member.memberNumber }}</span>
</h1>
<div v-if="member.pronouns" class="profile-pronouns-row">
<span class="profile-pronouns">{{ member.pronouns }}</span>
</div>
<div class="profile-meta">
<span v-if="member.circle" class="badge" :class="member.circle">
{{ circleLabels[member.circle] }}
</span>
<template v-if="member.studio">
<span class="meta-sep">&middot;</span>
<span class="profile-studio">{{ member.studio }}</span>
</template>
<template v-if="member.location || member.timeZone">
<span class="meta-sep">&middot;</span>
<span class="profile-location">
{{ [member.location, member.timeZone].filter(Boolean).join(' · ') }}
</span>
</template>
</div>
</div>
</div>
</div>
<!-- Bio Section -->
<div v-if="member.bio" class="profile-section">
<div class="section-label">About</div>
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
</div>
<!-- Location & Timezone -->
<div v-if="member.location || member.timeZone" class="profile-section">
<div class="section-label">Location</div>
<p class="profile-detail">
{{ [member.location, member.timeZone].filter(Boolean).join(" · ") }}
</p>
</div>
<!-- What I Do (craft tags, falling back to offering) -->
<div v-if="craftTagsDisplay.length > 0 || member.offering?.text" class="profile-section">
<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>
<!-- Community Connections (cooperative topics with states, falling back to lookingFor) -->
<div
v-if="connectionTopicsDisplay.length > 0 || member.lookingFor?.text || member.communityConnections?.details"
class="profile-section"
>
<div class="section-label">Community Connections</div>
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
<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>
<!-- 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
>
<!-- Right: Social Links (only when present) -->
<div v-if="hasSocialLinks" class="profile-hero-right">
<div class="section-label">Links</div>
<div class="social-links">
<a
v-if="member.socialLinks.website"
:href="member.socialLinks.website"
target="_blank"
rel="noopener noreferrer"
class="social-link"
>Website</a>
<a
v-if="member.socialLinks.itch"
:href="member.socialLinks.itch"
target="_blank"
rel="noopener noreferrer"
class="social-link"
>itch.io</a>
<a
v-if="member.socialLinks.mastodon"
:href="member.socialLinks.mastodon"
target="_blank"
rel="noopener noreferrer"
class="social-link"
>Mastodon</a>
<a
v-if="member.socialLinks.bluesky"
:href="member.socialLinks.bluesky"
target="_blank"
rel="noopener noreferrer"
class="social-link"
>Bluesky</a>
<a
v-if="member.socialLinks.linkedin"
:href="member.socialLinks.linkedin"
target="_blank"
rel="noopener noreferrer"
class="social-link"
>LinkedIn</a>
</div>
</div>
<div 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>
<!-- END HERO -->
<!-- Auth Notice -->
<div v-if="!isAuthenticated" 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',
})
"
<!-- SidebarLayout wraps all remaining sections -->
<SidebarLayout>
<!-- Bio: parch (inverted) block -->
<div v-if="member.bio" class="profile-section profile-section--parch">
<div class="section-label">About</div>
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
</div>
<!-- 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
</button>
</div>
<!-- Left: What I Do -->
<div class="profile-section">
<div class="section-label">What I Do</div>
<div v-if="craftTagsDisplay.length > 0" class="tag-list">
<span
v-for="tag in craftTagsDisplay"
:key="tag"
class="tag-pill"
>{{ tagLabel('craft', tag) }}</span>
</div>
<p v-if="member.offering?.text" class="profile-detail offering-text">
{{ member.offering.text }}
</p>
</div>
<!-- Back Link -->
<div class="profile-back">
<NuxtLink to="/members" class="back-link"> Back to Members</NuxtLink>
</div>
</div>
<!-- Right: Community Connections -->
<div class="profile-section">
<div class="section-label">Community Connections</div>
<div v-if="connectionTopicsDisplay.length > 0" class="tag-list">
<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>
</template>
@ -333,6 +333,11 @@ const peerAvailability = computed(() => {
);
});
// Whether the member has any social links (for hero layout)
const hasSocialLinks = computed(() =>
member.value?.socialLinks && Object.values(member.value.socialLinks).some(Boolean)
)
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
watch(
member,
@ -356,52 +361,77 @@ useHead({
</script>
<style scoped>
/* ====================================================
PROFILE PAGE
Full-bleed layout: no max-width, no centering.
Flex chain enables SidebarLayout's flex: 1 to work.
==================================================== */
.profile-page {
max-width: 720px;
margin: 0 auto;
padding: 0 24px 60px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- LOADING ---- */
/* ---- LOADING STATE ---- */
.loading-state {
padding: 80px 24px;
padding: 80px 32px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
font-family: "Commit Mono", monospace;
}
/* ---- ERROR / 404 ---- */
/* ---- ERROR / 404 STATE ---- */
.error-state {
padding: 80px 24px;
padding: 80px 32px;
text-align: center;
}
.error-title {
font-family: "Brygada 1918", serif;
font-size: 20px;
color: var(--text-dim);
margin-bottom: 6px;
}
.error-sub {
font-size: 12px;
color: var(--text-faint);
margin-bottom: 20px;
}
/* ---- HEADER ---- */
.profile-header {
display: flex;
align-items: center;
gap: 16px;
padding: 28px 0 24px;
/* ====================================================
HERO full-bleed, two-column when social links exist
==================================================== */
.profile-hero {
display: grid;
grid-template-columns: 1fr;
border-bottom: 1px dashed var(--border);
}
.profile-hero--with-links {
grid-template-columns: 1fr 1fr;
}
.profile-hero-left {
padding: 32px 32px 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
.profile-hero--with-links .profile-hero-left {
border-right: 1px dashed var(--border);
}
.profile-hero-right {
padding: 32px;
}
/* Avatar */
.profile-avatar {
width: 48px;
height: 48px;
width: 96px;
height: 96px;
background: var(--surface);
border: 1px dashed var(--border);
display: flex;
@ -410,131 +440,191 @@ useHead({
flex-shrink: 0;
overflow: hidden;
}
.profile-avatar-img {
width: 42px;
height: 42px;
width: 86px;
height: 86px;
object-fit: contain;
}
.profile-initials {
font-family: "Commit Mono", monospace;
font-size: 14px;
font-size: 28px;
color: var(--text-faint);
font-weight: 600;
}
/* Identity */
.profile-identity {
min-width: 0;
}
.profile-name {
font-family: "Brygada 1918", serif;
font-size: 22px;
font-size: 42px;
font-weight: 600;
color: var(--text-bright);
margin: 0;
line-height: 1.3;
line-height: 1.1;
letter-spacing: -0.02em;
}
.profile-member-number {
font-family: "Commit Mono", monospace;
font-size: 16px;
font-weight: 400;
color: var(--text-faint);
letter-spacing: 0.02em;
margin-left: 10px;
vertical-align: middle;
}
.profile-pronouns-row {
margin-top: 4px;
}
.profile-pronouns {
font-family: "Commit Mono", monospace;
font-size: 12px;
font-size: 11px;
color: var(--text-faint);
font-weight: 400;
margin-left: 8px;
letter-spacing: 0.04em;
}
.profile-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
margin-top: 10px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-dim);
}
.meta-sep {
color: var(--border);
}
.profile-studio,
.profile-location {
font-family: "Commit Mono", monospace;
font-size: 12px;
color: var(--text-dim);
}
.meta-sep {
color: var(--border);
/* Social links — vertical stack in hero right column */
.social-links {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.profile-studio {
.social-link {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--candle);
text-decoration: none;
padding: 5px 12px;
border: 1px dashed var(--border);
transition: border-color 0.15s, color 0.15s, border-style 0.15s;
display: block;
}
.social-link:hover {
border-color: var(--candle);
border-style: solid;
color: var(--text-bright);
}
/* ---- SECTIONS ---- */
/* ====================================================
SECTIONS inside SidebarLayout
==================================================== */
.profile-section {
padding: 20px 0;
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.section-label {
font-family: "Commit Mono", monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
margin-bottom: 10px;
/* Bio: parch (inverted) block */
.profile-section--parch {
background: var(--parch);
}
.profile-section--parch .section-label {
color: var(--parch-text-dim);
}
.profile-section--parch .profile-bio {
color: var(--parch-text);
}
.profile-section--parch .profile-bio :deep(a) {
color: var(--candle-faint);
text-decoration: underline;
text-underline-offset: 2px;
}
.profile-section--parch .profile-bio :deep(a:hover) {
color: var(--parch-text);
}
.profile-bio {
font-size: 13px;
line-height: 1.75;
color: var(--text-dim);
line-height: 1.7;
}
.profile-bio :deep(p) {
margin: 0 0 8px;
margin: 0 0 10px;
}
.profile-bio :deep(p:last-child) {
margin-bottom: 0;
}
.profile-bio :deep(a) {
color: var(--candle);
text-decoration: underline;
text-underline-offset: 2px;
}
.profile-bio :deep(a:hover) {
color: var(--ember);
}
/* ====================================================
TWO-COLUMN: Craft Tags + Community Connections
==================================================== */
.profile-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px dashed var(--border);
}
.profile-two-col .profile-section {
border-bottom: none;
}
.profile-two-col .profile-section:first-child {
border-right: 1px dashed var(--border);
}
/* ====================================================
SHARED SECTION ELEMENTS
==================================================== */
.profile-detail {
font-size: 13px;
color: var(--text-dim);
line-height: 1.6;
margin: 0;
}
.offering-text,
.looking-text,
.connection-details {
margin-top: 8px;
margin-top: 10px;
}
/* ---- TAGS ---- */
/* Tags */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-pill {
font-family: "Commit Mono", monospace;
font-size: 10px;
color: var(--text-dim);
padding: 2px 8px;
padding: 3px 8px;
border: 1px dashed var(--border);
white-space: nowrap;
}
.connection-pill {
display: inline-flex;
align-items: center;
gap: 4px;
}
.connection-state {
font-size: 9px;
text-transform: uppercase;
@ -542,97 +632,105 @@ useHead({
color: var(--text-faint);
}
/* ---- SOCIAL LINKS ---- */
.social-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* ====================================================
PEER SUPPORT
==================================================== */
.social-link {
font-family: "Commit Mono", monospace;
font-size: 11px;
color: var(--candle);
text-decoration: none;
padding: 3px 10px;
border: 1px dashed var(--border);
transition: all 0.15s;
}
.social-link:hover {
border-color: var(--candle);
color: var(--text-bright);
}
/* ---- PEER SUPPORT ---- */
.peer-group {
margin-bottom: 10px;
margin-bottom: 14px;
}
.peer-group:last-child {
.peer-group:last-of-type {
margin-bottom: 0;
}
.peer-label {
font-family: "Commit Mono", monospace;
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
display: block;
margin-bottom: 6px;
margin-bottom: 8px;
}
.peer-availability {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
/* ---- ACTIVITY ---- */
.activity-list {
/* ====================================================
ACTIVITY TIMELINE
==================================================== */
.activity-timeline {
display: flex;
flex-direction: column;
gap: 8px;
border-left: 1px dashed var(--border);
margin-left: 6px;
}
.activity-item {
.activity-entry {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
align-items: flex-start;
gap: 10px;
padding: 8px 0 8px 16px;
position: relative;
}
/* Dot connector on the timeline track */
.activity-entry::before {
content: "";
position: absolute;
left: -4px;
top: 14px;
width: 6px;
height: 6px;
border: 1px dashed var(--border);
background: var(--bg);
}
.activity-icon {
width: 14px;
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
margin-top: 1px;
}
.activity-text {
color: var(--text-dim);
flex: 1;
.activity-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.activity-text {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.activity-time {
font-size: 10px;
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
letter-spacing: 0.04em;
}
/* ---- AUTH NOTICE ---- */
/* ====================================================
AUTH NOTICE
==================================================== */
.auth-notice {
padding: 20px;
margin-top: 24px;
border: 1px dashed var(--candle-faint, var(--border));
border: 1px dashed var(--border);
padding: 24px;
text-align: center;
}
.auth-notice p {
font-size: 12px;
color: var(--text-dim);
margin: 0 0 12px;
}
/* ---- BACK LINK ---- */
.profile-back {
padding: 24px 0;
}
/* ====================================================
BACK LINK
==================================================== */
.profile-back {
padding: 24px 32px;
}
.back-link {
font-family: "Commit Mono", monospace;
font-size: 12px;
@ -640,34 +738,64 @@ useHead({
text-decoration: none;
transition: color 0.15s;
}
.back-link:hover {
color: var(--candle);
}
/* ---- RESPONSIVE ---- */
/* ====================================================
RESPONSIVE
==================================================== */
@media (max-width: 1024px) {
/* SidebarLayout sidebar hides itself at ≤1024px */
.profile-two-col {
grid-template-columns: 1fr;
}
.profile-two-col .profile-section:first-child {
border-right: none;
border-bottom: 1px dashed var(--border);
}
}
@media (max-width: 768px) {
.profile-page {
padding: 0 16px 40px;
.profile-hero,
.profile-hero--with-links {
grid-template-columns: 1fr;
}
.profile-header {
padding: 20px 0 18px;
gap: 12px;
.profile-hero--with-links .profile-hero-left {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.profile-hero-left {
padding: 24px 20px;
gap: 16px;
}
.profile-hero-right {
padding: 20px;
}
.profile-name {
font-size: 18px;
font-size: 28px;
}
.profile-pronouns {
display: block;
margin-left: 0;
margin-top: 2px;
.profile-avatar {
width: 72px;
height: 72px;
}
.profile-avatar-img {
width: 64px;
height: 64px;
}
.profile-initials {
font-size: 20px;
}
.profile-section {
padding: 16px 0;
padding: 20px;
}
.profile-back {
padding: 20px;
}
.social-links {
flex-direction: row;
flex-wrap: wrap;
}
}
</style>