Copy and layout improvements.
This commit is contained in:
parent
39eb9e039a
commit
02222a5c16
20 changed files with 464 additions and 652 deletions
|
|
@ -273,6 +273,90 @@ p a, blockquote a {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- SHARED USelectMenu STYLES ----
|
||||||
|
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
|
||||||
|
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
|
||||||
|
button.zine-select,
|
||||||
|
button.timezone-select {
|
||||||
|
display: flex !important;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px !important;
|
||||||
|
font-family: "Commit Mono", monospace !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--text-bright) !important;
|
||||||
|
background: var(--input-bg) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
min-height: 0;
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.zine-select:hover,
|
||||||
|
button.timezone-select:hover {
|
||||||
|
background: var(--input-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.zine-select:focus,
|
||||||
|
button.zine-select:focus-visible,
|
||||||
|
button.zine-select[aria-expanded="true"],
|
||||||
|
button.timezone-select:focus,
|
||||||
|
button.timezone-select:focus-visible,
|
||||||
|
button.timezone-select[aria-expanded="true"] {
|
||||||
|
border-color: var(--candle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-content {
|
||||||
|
background: var(--input-bg) !important;
|
||||||
|
border: 1px solid var(--border) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
|
||||||
|
--tw-ring-shadow: 0 0 #0000 !important;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||||
|
font-family: "Commit Mono", monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-input {
|
||||||
|
border-bottom: 1px dashed var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-input input {
|
||||||
|
font-family: "Commit Mono", monospace !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--text-bright) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
padding: 6px 8px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
--tw-ring-shadow: 0 0 #0000 !important;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-item {
|
||||||
|
font-family: "Commit Mono", monospace !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
padding: 6px 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-item::before {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-item[data-highlighted]::before,
|
||||||
|
.tz-item[data-highlighted]:not([data-disabled])::before {
|
||||||
|
background: var(--surface-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-item[data-highlighted],
|
||||||
|
.tz-item[data-highlighted]:not([data-disabled]) {
|
||||||
|
color: var(--text-bright) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- MOBILE ---- */
|
/* ---- MOBILE ---- */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
body {
|
body {
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,11 @@
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
Part of
|
Part of
|
||||||
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br />
|
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
|
||||||
A Canadian nonprofit
|
A Canadian nonprofit
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
Part of
|
Part of
|
||||||
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br />
|
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
|
||||||
A Canadian nonprofit
|
A Canadian nonprofit
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
@ -199,7 +199,6 @@ const youItems = [
|
||||||
{ label: "Dashboard", path: "/member/dashboard" },
|
{ label: "Dashboard", path: "/member/dashboard" },
|
||||||
{ label: "Profile", path: "/member/profile" },
|
{ label: "Profile", path: "/member/profile" },
|
||||||
{ label: "Account", path: "/member/account" },
|
{ label: "Account", path: "/member/account" },
|
||||||
{ label: "Activity Log", path: "/member/activity" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const exploreItems = [
|
const exploreItems = [
|
||||||
|
|
|
||||||
|
|
@ -120,23 +120,29 @@
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Full Name</label>
|
<label for="ticket-name">Full Name</label>
|
||||||
<input
|
<input
|
||||||
|
id="ticket-name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
|
autocomplete="name"
|
||||||
required
|
required
|
||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Email Address</label>
|
<label for="ticket-email">Email Address</label>
|
||||||
<input
|
<input
|
||||||
|
id="ticket-email"
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
required
|
required
|
||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|
@ -244,11 +250,13 @@ onMounted(async () => {
|
||||||
await fetchTicketInfo();
|
await fetchTicketInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchTicketInfo = async () => {
|
const fetchTicketInfo = async (emailOverride = null) => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const effectiveEmail = emailOverride || props.userEmail;
|
||||||
|
|
||||||
// First check if this event requires a series pass
|
// First check if this event requires a series pass
|
||||||
if (props.userEmail) {
|
if (props.userEmail) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -284,7 +292,7 @@ const fetchTicketInfo = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular ticket availability check
|
// Regular ticket availability check
|
||||||
const params = props.userEmail ? `?email=${props.userEmail}` : "";
|
const params = effectiveEmail ? `?email=${encodeURIComponent(effectiveEmail)}` : "";
|
||||||
const response = await $fetch(
|
const response = await $fetch(
|
||||||
`/api/events/${props.eventId}/tickets/available${params}`,
|
`/api/events/${props.eventId}/tickets/available${params}`,
|
||||||
);
|
);
|
||||||
|
|
@ -326,15 +334,17 @@ const handleSubmit = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name: form.value.name,
|
||||||
|
email: form.value.email,
|
||||||
|
};
|
||||||
|
if (transactionId) body.transactionId = transactionId;
|
||||||
|
|
||||||
const response = await $fetch(
|
const response = await $fetch(
|
||||||
`/api/events/${props.eventId}/tickets/purchase`,
|
`/api/events/${props.eventId}/tickets/purchase`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body,
|
||||||
name: form.value.name,
|
|
||||||
email: form.value.email,
|
|
||||||
transactionId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -347,7 +357,7 @@ const handleSubmit = async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
emit("success", response);
|
emit("success", response);
|
||||||
await fetchTicketInfo();
|
await fetchTicketInfo(form.value.email);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error purchasing ticket:", err);
|
console.error("Error purchasing ticket:", err);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
@click="$emit('update:modelValue', tier.amount)"
|
@click="$emit('update:modelValue', tier.amount)"
|
||||||
>
|
>
|
||||||
<span class="tier-amount">{{ tier.display }}</span>
|
<span class="tier-amount">{{ tier.display }}</span>
|
||||||
<span class="tier-label">{{ tier.label }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -40,7 +39,7 @@ defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
.tier-option {
|
.tier-option {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px 8px;
|
padding: 18px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
@ -67,30 +66,18 @@ defineEmits(["update:modelValue"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tier-amount {
|
.tier-amount {
|
||||||
font-size: 16px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: "Brygada 1918", serif;
|
font-family: "Brygada 1918", serif;
|
||||||
display: block;
|
display: block;
|
||||||
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tier-option.current .tier-amount {
|
.tier-option.current .tier-amount {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tier-label {
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-faint);
|
|
||||||
display: block;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-option.current .tier-label {
|
|
||||||
color: var(--candle-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.tier-picker {
|
.tier-picker {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,14 @@
|
||||||
Board Channels
|
Board Channels
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/site-content"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/site-content') }"
|
||||||
|
>
|
||||||
|
Site Content
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Site</div>
|
<div class="sidebar-section">Site</div>
|
||||||
|
|
@ -76,7 +84,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<span class="admin-tag">admin</span><br />
|
<span class="admin-tag">admin</span><br >
|
||||||
<a href="#" @click.prevent="logout">Sign out</a>
|
<a href="#" @click.prevent="logout">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -170,6 +178,15 @@
|
||||||
Board Channels
|
Board Channels
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/site-content"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/site-content') }"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Site Content
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-section">Site</div>
|
<div class="sidebar-section">Site</div>
|
||||||
|
|
@ -190,7 +207,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<span class="admin-tag">admin</span><br />
|
<span class="admin-tag">admin</span><br >
|
||||||
<a href="#" @click.prevent="logout">Sign out</a>
|
<a href="#" @click.prevent="logout">Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<PageShell title="Bulletin Board" :subtitle="pageSubtitle">
|
<PageShell title="Bulletin Board" :subtitle="pageSubtitle">
|
||||||
|
<p class="page-intro">
|
||||||
|
Make offers and requests related to shared interests and cooperative
|
||||||
|
topics.
|
||||||
|
</p>
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<button
|
<button
|
||||||
v-if="cooperativeTags.length > 0"
|
v-if="cooperativeTags.length > 0"
|
||||||
|
|
@ -204,6 +208,14 @@ onMounted(async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.page-intro {
|
||||||
|
padding: 12px 24px 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
.action-bar {
|
.action-bar {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,18 @@
|
||||||
|
|
||||||
<!-- FILTER BAR -->
|
<!-- FILTER BAR -->
|
||||||
<FilterBar v-model="activeFilter" :filters="filterOptions">
|
<FilterBar v-model="activeFilter" :filters="filterOptions">
|
||||||
<label class="filter-toggle">
|
<button
|
||||||
<input v-model="includePastEvents" type="checkbox" /> Show past events
|
type="button"
|
||||||
</label>
|
class="past-toggle"
|
||||||
|
:class="{ active: includePastEvents }"
|
||||||
|
:aria-pressed="includePastEvents"
|
||||||
|
@click="includePastEvents = !includePastEvents"
|
||||||
|
>
|
||||||
|
<span class="past-toggle-box" aria-hidden="true">
|
||||||
|
<span v-if="includePastEvents" class="past-toggle-check">×</span>
|
||||||
|
</span>
|
||||||
|
Show past events
|
||||||
|
</button>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<!-- EVENT LIST -->
|
<!-- EVENT LIST -->
|
||||||
|
|
@ -53,6 +62,14 @@
|
||||||
<span :class="{ 'seats-warn': isAlmostFull(event) }">
|
<span :class="{ 'seats-warn': isAlmostFull(event) }">
|
||||||
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
|
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="isSoldOut(event)" class="capacity-badge sold-out"
|
||||||
|
>Sold out</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else-if="isAlmostFull(event)"
|
||||||
|
class="capacity-badge limited"
|
||||||
|
>Limited tickets</span
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>Open</template>
|
<template v-else>Open</template>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -154,9 +171,15 @@ const formatLocation = (event) => {
|
||||||
return event.location;
|
return event.location;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSoldOut = (event) => {
|
||||||
|
if (!event.maxAttendees) return false;
|
||||||
|
return (event.registeredCount || 0) >= event.maxAttendees;
|
||||||
|
};
|
||||||
|
|
||||||
const isAlmostFull = (event) => {
|
const isAlmostFull = (event) => {
|
||||||
if (!event.maxAttendees) return false;
|
if (!event.maxAttendees) return false;
|
||||||
return (event.registeredCount || 0) / event.maxAttendees > 0.8;
|
if (isSoldOut(event)) return false;
|
||||||
|
return (event.registeredCount || 0) / event.maxAttendees >= 0.8;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -289,10 +312,29 @@ const isAlmostFull = (event) => {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.seats-warn {
|
.seats-warn {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
.capacity-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px dashed currentColor;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.capacity-badge.limited {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
.capacity-badge.sold-out {
|
||||||
|
color: var(--text-faint);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
.event-badges {
|
.event-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -358,17 +400,43 @@ const isAlmostFull = (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.filter-toggle {
|
.past-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 4px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.filter-toggle input {
|
.past-toggle:hover {
|
||||||
accent-color: var(--candle-dim);
|
border-color: var(--candle-faint);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.past-toggle.active {
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
.past-toggle-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.past-toggle-check {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|
|
||||||
|
|
@ -87,18 +87,24 @@
|
||||||
>
|
>
|
||||||
From the Wiki
|
From the Wiki
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="hasCustomWikiFeature">
|
||||||
|
<h2>{{ wikiFeature.title || DEFAULT_WIKI_FEATURE_TITLE }}</h2>
|
||||||
|
<p v-for="(para, i) in customWikiParagraphs" :key="i">{{ para }}</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<h2>What is a cooperative studio?</h2>
|
<h2>What is a cooperative studio?</h2>
|
||||||
<p>
|
<p>
|
||||||
A cooperative studio is a game development company owned and governed by
|
A cooperative studio is a game development company owned and governed
|
||||||
the people who work there. Decisions are made collectively. Profits are
|
by the people who work there. Decisions are made collectively. Profits
|
||||||
shared according to contribution, not ownership stake.
|
are shared according to contribution, not ownership stake.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The games industry is full of stories about crunch, layoffs, and studios
|
The games industry is full of stories about crunch, layoffs, and
|
||||||
that extract value from workers. Cooperatives are one alternative — not
|
studios that extract value from workers. Cooperatives are one
|
||||||
the only one, but one worth
|
alternative — not the only one, but one worth
|
||||||
<a href="https://wiki.ghostguild.org">practicing together</a>.
|
<a href="https://wiki.ghostguild.org">practicing together</a>.
|
||||||
</p>
|
</p>
|
||||||
|
</template>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://wiki.ghostguild.org">Read more in the wiki →</a>
|
<a href="https://wiki.ghostguild.org">Read more in the wiki →</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -121,6 +127,25 @@ const { data: wikiArticles } = await useFetch("/api/wiki/recent", {
|
||||||
default: () => [],
|
default: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
|
||||||
|
|
||||||
|
const { data: wikiFeature } = await useFetch(
|
||||||
|
"/api/site-content/homepage.wiki_feature",
|
||||||
|
{ default: () => ({ title: "", body: "" }) }
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasCustomWikiFeature = computed(
|
||||||
|
() => !!wikiFeature.value?.body?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
const customWikiParagraphs = computed(() => {
|
||||||
|
const body = wikiFeature.value?.body?.trim() || "";
|
||||||
|
return body
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
const circleData = [
|
const circleData = [
|
||||||
{
|
{
|
||||||
value: "community",
|
value: "community",
|
||||||
|
|
|
||||||
|
|
@ -63,19 +63,12 @@
|
||||||
<ParchmentInset>
|
<ParchmentInset>
|
||||||
<h2>How membership works</h2>
|
<h2>How membership works</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>Full access to the knowledge commons, Slack, and peer support</li>
|
||||||
Full access to the knowledge commons, events, Slack community, and
|
<li>Free access to all Ghost Guild events</li>
|
||||||
peer support
|
<li>One member, one vote</li>
|
||||||
</li>
|
<li>Your circle reflects where you are, not rank</li>
|
||||||
<li>One member, one vote in all decisions</li>
|
<li>Pay what you can ($0–$50+/month, separate from circle)</li>
|
||||||
<li>Your circle is where you are in your journey, not rank</li>
|
<li>Higher contributions create solidarity spots for others</li>
|
||||||
<li>
|
|
||||||
Your contribution is what you can afford ($0--50+/month, separate
|
|
||||||
from your circle)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Higher contributions create solidarity spots for those who need them
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</ParchmentInset>
|
</ParchmentInset>
|
||||||
|
|
||||||
|
|
@ -172,7 +165,7 @@
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
required
|
required
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="join-email">Email Address</label>
|
<label class="form-label" for="join-email">Email Address</label>
|
||||||
|
|
@ -183,7 +176,7 @@
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
required
|
required
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Circle</label>
|
<label class="form-label">Circle</label>
|
||||||
|
|
@ -195,7 +188,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="community"
|
value="community"
|
||||||
/>
|
>
|
||||||
<label for="circle-community">
|
<label for="circle-community">
|
||||||
<span
|
<span
|
||||||
class="circle-label-name"
|
class="circle-label-name"
|
||||||
|
|
@ -212,7 +205,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="founder"
|
value="founder"
|
||||||
/>
|
>
|
||||||
<label for="circle-founder">
|
<label for="circle-founder">
|
||||||
<span
|
<span
|
||||||
class="circle-label-name"
|
class="circle-label-name"
|
||||||
|
|
@ -229,7 +222,7 @@
|
||||||
type="radio"
|
type="radio"
|
||||||
name="circle"
|
name="circle"
|
||||||
value="practitioner"
|
value="practitioner"
|
||||||
/>
|
>
|
||||||
<label for="circle-practitioner">
|
<label for="circle-practitioner">
|
||||||
<span
|
<span
|
||||||
class="circle-label-name"
|
class="circle-label-name"
|
||||||
|
|
@ -245,21 +238,18 @@
|
||||||
<label class="form-label" for="join-contribution"
|
<label class="form-label" for="join-contribution"
|
||||||
>Monthly Contribution</label
|
>Monthly Contribution</label
|
||||||
>
|
>
|
||||||
<select
|
<USelectMenu
|
||||||
id="join-contribution"
|
id="join-contribution"
|
||||||
v-model="form.contributionTier"
|
v-model="form.contributionTier"
|
||||||
class="form-select"
|
:items="contributionItems"
|
||||||
>
|
value-key="value"
|
||||||
<option value="0">$0/mo -- I need support right now</option>
|
:search-input="false"
|
||||||
<option value="5">$5/mo -- I can contribute</option>
|
class="zine-select"
|
||||||
<option value="15">
|
:ui="{
|
||||||
$15/mo -- I can sustain the community (suggested)
|
content: 'tz-content',
|
||||||
</option>
|
item: 'tz-item',
|
||||||
<option value="30">$30/mo -- I can support others too</option>
|
}"
|
||||||
<option value="50">
|
/>
|
||||||
$50/mo -- I want to sponsor multiple members
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<button
|
<button
|
||||||
|
|
@ -434,6 +424,15 @@ const circleOptions = getCircleOptions();
|
||||||
// Contribution options from central config
|
// Contribution options from central config
|
||||||
const contributionOptions = getContributionOptions();
|
const contributionOptions = getContributionOptions();
|
||||||
|
|
||||||
|
// Minimal labels for the dropdown (tier descriptions live in the left column).
|
||||||
|
const contributionItems = [
|
||||||
|
{ value: "0", label: "$0/mo" },
|
||||||
|
{ value: "5", label: "$5/mo" },
|
||||||
|
{ value: "15", label: "$15/mo (suggested)" },
|
||||||
|
{ value: "30", label: "$30/mo" },
|
||||||
|
{ value: "50", label: "$50/mo" },
|
||||||
|
];
|
||||||
|
|
||||||
// Initialize composables
|
// Initialize composables
|
||||||
const {
|
const {
|
||||||
initializeHelcimPay,
|
initializeHelcimPay,
|
||||||
|
|
@ -671,11 +670,12 @@ onUnmounted(() => {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
:deep(.parchment-inset ul li::before) {
|
:deep(.parchment-inset ul li::before) {
|
||||||
content: "--";
|
content: "›";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
color: var(--candle-dim);
|
color: var(--candle-faint);
|
||||||
opacity: 0.5;
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parchment-link {
|
.parchment-link {
|
||||||
|
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
<template>
|
|
||||||
<PageShell
|
|
||||||
title="Activity Log"
|
|
||||||
subtitle="Your recent activity"
|
|
||||||
>
|
|
||||||
<ColumnsLayout cols="events-sidebar" :limit="5">
|
|
||||||
<ClientOnly>
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="loading && !entries.length" class="state-box">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p class="state-text">Loading activity...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timeline -->
|
|
||||||
<div v-else-if="entries.length" class="timeline-wrap">
|
|
||||||
<div class="timeline">
|
|
||||||
<div v-for="entry in entries" :key="entry._id" class="tl-item">
|
|
||||||
<div class="tl-dot">
|
|
||||||
<UIcon :name="getActivity(entry).icon" class="tl-icon" />
|
|
||||||
</div>
|
|
||||||
<div class="tl-time">{{ formatDate(entry.timestamp) }}</div>
|
|
||||||
<div class="tl-text">
|
|
||||||
<template v-if="getActivity(entry).link">
|
|
||||||
<span>{{ getActivity(entry).text.split(getActivity(entry).linkText)[0] }}</span>
|
|
||||||
<NuxtLink :to="getActivity(entry).link" class="tl-link">{{ getActivity(entry).linkText }}</NuxtLink>
|
|
||||||
</template>
|
|
||||||
<span v-else>{{ getActivity(entry).text }}</span>
|
|
||||||
<span v-if="entry.performedBy" class="tl-admin-badge">admin</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email body expandable -->
|
|
||||||
<div v-if="entry.type === 'email_sent' && getActivity(entry).emailBody" class="tl-email">
|
|
||||||
<button class="tl-email-toggle" @click="toggleEmail(entry._id)">
|
|
||||||
{{ expandedEmails[entry._id] ? 'Hide email' : 'View email' }}
|
|
||||||
</button>
|
|
||||||
<div v-if="expandedEmails[entry._id]" class="dashed-box tl-email-body">
|
|
||||||
<pre>{{ getActivity(entry).emailBody }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Load More -->
|
|
||||||
<div v-if="hasMore" class="load-more">
|
|
||||||
<button class="btn" :disabled="loadingMore" @click="loadMore">
|
|
||||||
{{ loadingMore ? 'Loading...' : 'Load More' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-else class="state-box">
|
|
||||||
<div class="state-icon">
|
|
||||||
<UIcon name="i-lucide-activity" />
|
|
||||||
</div>
|
|
||||||
<h2 class="state-heading">No activity yet</h2>
|
|
||||||
<p class="state-text">Your activity will appear here as you use the Guild</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #fallback>
|
|
||||||
<div class="state-box">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p class="state-text">Loading activity...</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</ColumnsLayout>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { formatActivity } from '~/utils/activityText'
|
|
||||||
|
|
||||||
const entries = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadingMore = ref(false)
|
|
||||||
const hasMore = ref(false)
|
|
||||||
const nextCursor = ref(null)
|
|
||||||
const expandedEmails = ref({})
|
|
||||||
|
|
||||||
const getActivity = (entry) => formatActivity(entry)
|
|
||||||
|
|
||||||
const toggleEmail = (id) => {
|
|
||||||
expandedEmails.value[id] = !expandedEmails.value[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (date) => {
|
|
||||||
const now = new Date()
|
|
||||||
const d = new Date(date)
|
|
||||||
const diffInSeconds = Math.floor((now - d) / 1000)
|
|
||||||
|
|
||||||
if (diffInSeconds < 60) return 'just now'
|
|
||||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
|
||||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
|
||||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
|
||||||
|
|
||||||
return d.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadEntries = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await $fetch('/api/members/me/activity', {
|
|
||||||
params: { limit: 20 }
|
|
||||||
})
|
|
||||||
entries.value = data.entries
|
|
||||||
hasMore.value = data.hasMore
|
|
||||||
nextCursor.value = data.nextCursor
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load activity:', err)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadMore = async () => {
|
|
||||||
if (!nextCursor.value) return
|
|
||||||
loadingMore.value = true
|
|
||||||
try {
|
|
||||||
const data = await $fetch('/api/members/me/activity', {
|
|
||||||
params: { limit: 20, before: nextCursor.value }
|
|
||||||
})
|
|
||||||
entries.value.push(...data.entries)
|
|
||||||
hasMore.value = data.hasMore
|
|
||||||
nextCursor.value = data.nextCursor
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load more activity:', err)
|
|
||||||
} finally {
|
|
||||||
loadingMore.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadEntries)
|
|
||||||
|
|
||||||
useHead({ title: 'Activity Log - Ghost Guild' })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ---- STATE BOXES ---- */
|
|
||||||
.state-box {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 64px 32px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-heading {
|
|
||||||
font-family: 'Brygada 1918', serif;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-bright);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px dashed var(--candle);
|
|
||||||
border-top-color: transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- TIMELINE ---- */
|
|
||||||
.timeline-wrap {
|
|
||||||
padding: 24px 32px 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 11px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 1px;
|
|
||||||
border-left: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-item {
|
|
||||||
position: relative;
|
|
||||||
padding: 0 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-item:last-child {
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-dot {
|
|
||||||
position: absolute;
|
|
||||||
left: -32px;
|
|
||||||
top: 2px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-icon {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-time {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-link {
|
|
||||||
color: var(--candle);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-admin-badge {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
padding: 1px 5px;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- EMAIL EXPANDABLE ---- */
|
|
||||||
.tl-email {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-email-toggle {
|
|
||||||
font-family: 'Commit Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-email-toggle:hover {
|
|
||||||
color: var(--candle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-email-body {
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.6;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl-email-body pre {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
font-family: 'Commit Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- LOAD MORE ---- */
|
|
||||||
.load-more {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.timeline-wrap {
|
|
||||||
padding: 20px 20px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-box {
|
|
||||||
padding: 48px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -90,8 +90,8 @@
|
||||||
<strong>How to Subscribe to Your Calendar</strong>
|
<strong>How to Subscribe to Your Calendar</strong>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="showCalendarInstructions = false"
|
|
||||||
class="ci-close"
|
class="ci-close"
|
||||||
|
@click="showCalendarInstructions = false"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -192,14 +192,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-block">
|
<div class="content-block">
|
||||||
<div class="section-label">Community</div>
|
<div class="section-label">Bulletin Board</div>
|
||||||
<DashedBox>
|
<DashedBox>
|
||||||
<p class="peer-text">
|
<p class="peer-text">
|
||||||
Connect with other members through shared interests and
|
Make offers and requests related to shared interests and
|
||||||
cooperative topics.
|
cooperative topics.
|
||||||
</p>
|
</p>
|
||||||
<NuxtLink to="/board" class="section-link">
|
<NuxtLink to="/board" class="section-link">
|
||||||
Browse the board →
|
Browse the Bulletin Board →
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</DashedBox>
|
</DashedBox>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,7 @@
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- PAGE HEADER -->
|
<!-- PAGE HEADER -->
|
||||||
<PageHeader
|
<PageHeader title="Edit Profile">
|
||||||
title="Edit Profile"
|
|
||||||
subtitle="How you appear to other members"
|
|
||||||
>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="
|
v-if="
|
||||||
memberId &&
|
memberId &&
|
||||||
|
|
@ -234,6 +231,44 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
|
<PageSection divider="top">
|
||||||
|
<div class="section-label">Recent Activity</div>
|
||||||
|
|
||||||
|
<div v-if="activityLoading" class="activity-empty">
|
||||||
|
Loading activity…
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="recentActivity.length" class="activity-list">
|
||||||
|
<li
|
||||||
|
v-for="entry in recentActivity"
|
||||||
|
:key="entry._id"
|
||||||
|
class="activity-item"
|
||||||
|
>
|
||||||
|
<div class="activity-time">
|
||||||
|
{{ formatActivityTime(entry.timestamp) }}
|
||||||
|
</div>
|
||||||
|
<div class="activity-text">
|
||||||
|
<template v-if="formatActivity(entry).link">
|
||||||
|
<span>{{
|
||||||
|
formatActivity(entry).text.split(
|
||||||
|
formatActivity(entry).linkText,
|
||||||
|
)[0]
|
||||||
|
}}</span>
|
||||||
|
<NuxtLink
|
||||||
|
:to="formatActivity(entry).link"
|
||||||
|
class="activity-link"
|
||||||
|
>
|
||||||
|
{{ formatActivity(entry).linkText }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ formatActivity(entry).text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="activity-empty">
|
||||||
|
Your activity will appear here as you use the Guild.
|
||||||
|
</div>
|
||||||
|
</PageSection>
|
||||||
</template>
|
</template>
|
||||||
</ColumnsLayout>
|
</ColumnsLayout>
|
||||||
|
|
||||||
|
|
@ -269,6 +304,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
|
import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
|
||||||
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
||||||
|
import { formatActivity } from "~/utils/activityText";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: "auth",
|
middleware: "auth",
|
||||||
|
|
@ -333,13 +369,8 @@ const timezoneItems = computed(() => {
|
||||||
const notificationToggles = [
|
const notificationToggles = [
|
||||||
{
|
{
|
||||||
key: "events",
|
key: "events",
|
||||||
label: "Event reminders",
|
label: "Registration & cancellation emails",
|
||||||
sub: "Get notified about upcoming events",
|
sub: "Confirmation when you register for an event, and notice if it's cancelled",
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "updates",
|
|
||||||
label: "Community updates",
|
|
||||||
sub: "New posts from members you follow",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -370,7 +401,6 @@ const formData = reactive({
|
||||||
boardSlackHandle: "",
|
boardSlackHandle: "",
|
||||||
notifications: {
|
notifications: {
|
||||||
events: true,
|
events: true,
|
||||||
updates: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -378,6 +408,39 @@ const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const initialData = ref(null);
|
const initialData = ref(null);
|
||||||
|
|
||||||
|
const recentActivity = ref([]);
|
||||||
|
const activityLoading = ref(false);
|
||||||
|
|
||||||
|
const formatActivityTime = (date) => {
|
||||||
|
const now = new Date();
|
||||||
|
const d = new Date(date);
|
||||||
|
const diff = Math.floor((now - d) / 1000);
|
||||||
|
if (diff < 60) return "just now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRecentActivity = async () => {
|
||||||
|
activityLoading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await $fetch("/api/members/me/activity", {
|
||||||
|
params: { limit: 5 },
|
||||||
|
});
|
||||||
|
recentActivity.value = data.entries || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load activity:", err);
|
||||||
|
recentActivity.value = [];
|
||||||
|
} finally {
|
||||||
|
activityLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const memberId = computed(() => memberData.value?._id || memberData.value?.id);
|
const memberId = computed(() => memberData.value?._id || memberData.value?.id);
|
||||||
|
|
||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
|
|
@ -405,7 +468,6 @@ const loadProfile = () => {
|
||||||
|
|
||||||
const notifs = memberData.value.notifications || {};
|
const notifs = memberData.value.notifications || {};
|
||||||
formData.notifications.events = notifs.events ?? true;
|
formData.notifications.events = notifs.events ?? true;
|
||||||
formData.notifications.updates = notifs.updates ?? true;
|
|
||||||
|
|
||||||
initialData.value = JSON.parse(JSON.stringify(formData));
|
initialData.value = JSON.parse(JSON.stringify(formData));
|
||||||
};
|
};
|
||||||
|
|
@ -458,7 +520,10 @@ onMounted(async () => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
|
|
||||||
if (memberId.value) {
|
if (memberId.value) {
|
||||||
await fetchPosts({ author: memberId.value });
|
await Promise.allSettled([
|
||||||
|
fetchPosts({ author: memberId.value }),
|
||||||
|
loadRecentActivity(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -712,6 +777,41 @@ useHead({
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- RECENT ACTIVITY ---- */
|
||||||
|
.activity-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.activity-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.activity-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.activity-text {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.activity-link {
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.activity-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.activity-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- DISABLED BUTTON ---- */
|
/* ---- DISABLED BUTTON ---- */
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|
@ -743,84 +843,3 @@ useHead({
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Non-scoped: targets USelectMenu button root which does not inherit scoped data attribute. */
|
|
||||||
button.timezone-select {
|
|
||||||
display: flex !important;
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 8px !important;
|
|
||||||
font-family: "Commit Mono", monospace !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
color: var(--text-bright) !important;
|
|
||||||
background: var(--input-bg) !important;
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
min-height: 0;
|
|
||||||
--tw-ring-shadow: 0 0 #0000;
|
|
||||||
--tw-ring-offset-shadow: 0 0 #0000;
|
|
||||||
--tw-ring-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.timezone-select:hover {
|
|
||||||
background: var(--input-bg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.timezone-select:focus,
|
|
||||||
button.timezone-select:focus-visible,
|
|
||||||
button.timezone-select[aria-expanded="true"] {
|
|
||||||
border-color: var(--candle) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Popup content (portalled to body) */
|
|
||||||
.tz-content {
|
|
||||||
background: var(--input-bg) !important;
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
|
|
||||||
--tw-ring-shadow: 0 0 #0000 !important;
|
|
||||||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
|
||||||
font-family: "Commit Mono", monospace !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search input wrapper inside popup */
|
|
||||||
.tz-input {
|
|
||||||
border-bottom: 1px dashed var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-input input {
|
|
||||||
font-family: "Commit Mono", monospace !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
color: var(--text-bright) !important;
|
|
||||||
background: transparent !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
padding: 6px 8px !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
--tw-ring-shadow: 0 0 #0000 !important;
|
|
||||||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Option rows */
|
|
||||||
.tz-item {
|
|
||||||
font-family: "Commit Mono", monospace !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
padding: 6px 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-item::before {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-item[data-highlighted]::before,
|
|
||||||
.tz-item[data-highlighted]:not([data-disabled])::before {
|
|
||||||
background: var(--surface-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tz-item[data-highlighted],
|
|
||||||
.tz-item[data-highlighted]:not([data-disabled]) {
|
|
||||||
color: var(--text-bright) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<PageShell
|
<PageShell title="Members">
|
||||||
title="Members"
|
|
||||||
:subtitle="pageSubtitle"
|
|
||||||
>
|
|
||||||
<!-- Filter Bar -->
|
<!-- Filter Bar -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<input
|
<input
|
||||||
|
|
@ -11,34 +8,25 @@
|
||||||
class="filter-search"
|
class="filter-search"
|
||||||
placeholder="Search members..."
|
placeholder="Search members..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
>
|
||||||
<select
|
<USelectMenu
|
||||||
v-model="selectedCircle"
|
v-model="selectedCircle"
|
||||||
class="filter-select"
|
:items="circleOptions"
|
||||||
@change="loadMembers"
|
value-key="value"
|
||||||
>
|
:search-input="false"
|
||||||
<option
|
class="zine-select circle-select"
|
||||||
v-for="opt in circleOptions"
|
:ui="{
|
||||||
:key="opt.value"
|
content: 'tz-content',
|
||||||
:value="opt.value"
|
item: 'tz-item',
|
||||||
>
|
}"
|
||||||
{{ opt.label }}
|
@update:model-value="loadMembers"
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<label class="filter-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="peerSupportFilter === 'true'"
|
|
||||||
@change="togglePeerSupport"
|
|
||||||
/>
|
/>
|
||||||
Offering support
|
<button
|
||||||
</label>
|
v-if="craftTagOptions.length > 0"
|
||||||
<span class="filter-count">Showing {{ totalCount }} member{{ totalCount === 1 ? '' : 's' }} across 3 circles</span>
|
type="button"
|
||||||
</div>
|
class="drawer-btn"
|
||||||
|
@click="showTagsDrawer = !showTagsDrawer"
|
||||||
<!-- Tags Drawer Toggle -->
|
>
|
||||||
<div v-if="craftTagOptions.length > 0" class="tags-drawer-toggle">
|
|
||||||
<button type="button" class="drawer-btn" @click="showTagsDrawer = !showTagsDrawer">
|
|
||||||
Tags...
|
Tags...
|
||||||
<span v-if="directoryCraftTags.length > 0" class="tag-count-badge">{{ directoryCraftTags.length }}</span>
|
<span v-if="directoryCraftTags.length > 0" class="tag-count-badge">{{ directoryCraftTags.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -76,10 +64,6 @@
|
||||||
{{ circleLabels[selectedCircle] }}
|
{{ circleLabels[selectedCircle] }}
|
||||||
<button type="button" @click="clearCircleFilter">×</button>
|
<button type="button" @click="clearCircleFilter">×</button>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="peerSupportFilter === 'true'" class="af-tag">
|
|
||||||
Offering Support
|
|
||||||
<button type="button" @click="clearPeerSupportFilter">×</button>
|
|
||||||
</span>
|
|
||||||
<span v-for="slug in directoryCraftTags" :key="'c-' + slug" class="af-tag">
|
<span v-for="slug in directoryCraftTags" :key="'c-' + slug" class="af-tag">
|
||||||
{{ craftTagLabel(slug) }}
|
{{ craftTagLabel(slug) }}
|
||||||
<button type="button" @click="toggleDirectoryCraftTag(slug)">×</button>
|
<button type="button" @click="toggleDirectoryCraftTag(slug)">×</button>
|
||||||
|
|
@ -108,7 +92,7 @@
|
||||||
:src="`/ghosties/Ghost-${capitalize(member.avatar)}.png`"
|
:src="`/ghosties/Ghost-${capitalize(member.avatar)}.png`"
|
||||||
:alt="member.name"
|
:alt="member.name"
|
||||||
class="mc-avatar-img"
|
class="mc-avatar-img"
|
||||||
/>
|
>
|
||||||
<span v-else>{{ getInitials(member.name) }}</span>
|
<span v-else>{{ getInitials(member.name) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-info">
|
<div class="mc-info">
|
||||||
|
|
@ -130,16 +114,16 @@
|
||||||
v-if="member.bio"
|
v-if="member.bio"
|
||||||
class="mc-bio"
|
class="mc-bio"
|
||||||
v-html="renderMarkdown(member.bio)"
|
v-html="renderMarkdown(member.bio)"
|
||||||
></div>
|
/>
|
||||||
|
|
||||||
<div v-if="member.craftTags?.length > 0" class="mc-tags">
|
<div v-if="member.craftTags?.length > 0" class="mc-tags">
|
||||||
<span class="tag-label">Craft:</span>
|
<span class="tag-label">Craft:</span>
|
||||||
<span
|
<span
|
||||||
v-for="tag in member.craftTags.slice(0, 5)"
|
v-for="tag in member.craftTags.slice(0, 3)"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
>{{ craftTagLabel(tag) }}</span>
|
>{{ craftTagLabel(tag) }}</span>
|
||||||
<span v-if="member.craftTags.length > 5" class="tag-overflow">+{{ member.craftTags.length - 5 }}</span>
|
<span v-if="member.craftTags.length > 3" class="tag-overflow">+{{ member.craftTags.length - 3 }} more</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -159,7 +143,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({ middleware: ['members-auth'] })
|
definePageMeta({ middleware: ['members-auth'] })
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const { render: renderMarkdown } = useMarkdown()
|
const { render: renderMarkdown } = useMarkdown()
|
||||||
|
|
||||||
// ---- Directory state ----
|
// ---- Directory state ----
|
||||||
|
|
@ -168,7 +151,6 @@ const totalCount = ref(0)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedCircle = ref('all')
|
const selectedCircle = ref('all')
|
||||||
const peerSupportFilter = ref('all')
|
|
||||||
const directoryCraftTags = ref([])
|
const directoryCraftTags = ref([])
|
||||||
const craftTagOptions = ref([])
|
const craftTagOptions = ref([])
|
||||||
const showAllTags = ref(false)
|
const showAllTags = ref(false)
|
||||||
|
|
@ -218,14 +200,9 @@ const visibleTagOptions = computed(() =>
|
||||||
|
|
||||||
const hasActiveFilters = computed(() =>
|
const hasActiveFilters = computed(() =>
|
||||||
(selectedCircle.value && selectedCircle.value !== 'all') ||
|
(selectedCircle.value && selectedCircle.value !== 'all') ||
|
||||||
peerSupportFilter.value === 'true' ||
|
|
||||||
directoryCraftTags.value.length > 0
|
directoryCraftTags.value.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
const pageSubtitle = computed(() =>
|
|
||||||
`${totalCount.value} member${totalCount.value === 1 ? '' : 's'} across 3 circles`
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Load members ----
|
// ---- Load members ----
|
||||||
const loadMembers = async () => {
|
const loadMembers = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -233,7 +210,6 @@ const loadMembers = async () => {
|
||||||
const params = {}
|
const params = {}
|
||||||
if (searchQuery.value) params.search = searchQuery.value
|
if (searchQuery.value) params.search = searchQuery.value
|
||||||
if (selectedCircle.value && selectedCircle.value !== 'all') params.circle = selectedCircle.value
|
if (selectedCircle.value && selectedCircle.value !== 'all') params.circle = selectedCircle.value
|
||||||
if (peerSupportFilter.value === 'true') params.peerSupport = 'true'
|
|
||||||
if (directoryCraftTags.value.length === 1) params.craftTag = directoryCraftTags.value[0]
|
if (directoryCraftTags.value.length === 1) params.craftTag = directoryCraftTags.value[0]
|
||||||
|
|
||||||
const data = await $fetch('/api/members/directory', { params })
|
const data = await $fetch('/api/members/directory', { params })
|
||||||
|
|
@ -266,11 +242,6 @@ const loadTagOptions = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Filter helpers ----
|
// ---- Filter helpers ----
|
||||||
const togglePeerSupport = (e) => {
|
|
||||||
peerSupportFilter.value = e.target.checked ? 'true' : 'all'
|
|
||||||
loadMembers()
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchTimeout
|
let searchTimeout
|
||||||
const debouncedSearch = () => {
|
const debouncedSearch = () => {
|
||||||
clearTimeout(searchTimeout)
|
clearTimeout(searchTimeout)
|
||||||
|
|
@ -294,15 +265,9 @@ const clearCircleFilter = () => {
|
||||||
loadMembers()
|
loadMembers()
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearPeerSupportFilter = () => {
|
|
||||||
peerSupportFilter.value = 'all'
|
|
||||||
loadMembers()
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
const clearAllFilters = () => {
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
selectedCircle.value = 'all'
|
selectedCircle.value = 'all'
|
||||||
peerSupportFilter.value = 'all'
|
|
||||||
directoryCraftTags.value = []
|
directoryCraftTags.value = []
|
||||||
showTagsDrawer.value = false
|
showTagsDrawer.value = false
|
||||||
loadMembers()
|
loadMembers()
|
||||||
|
|
@ -325,10 +290,6 @@ useHead({
|
||||||
|
|
||||||
// ---- Init ----
|
// ---- Init ----
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (route.query.peerSupport === 'true') {
|
|
||||||
peerSupportFilter.value = 'true'
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadTagOptions()
|
await loadTagOptions()
|
||||||
await loadMembers()
|
await loadMembers()
|
||||||
})
|
})
|
||||||
|
|
@ -363,51 +324,15 @@ onMounted(async () => {
|
||||||
border-color: var(--candle-faint);
|
border-color: var(--candle-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
/* Constrain the circle USelectMenu button width so it doesn't stretch. */
|
||||||
font-family: "Commit Mono", monospace;
|
:deep(.circle-select) {
|
||||||
font-size: 11px;
|
width: auto !important;
|
||||||
padding: 5px 10px;
|
min-width: 150px;
|
||||||
border: 1px dashed var(--border);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%238a7e6a' stroke-width='1.2'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 8px center;
|
|
||||||
padding-right: 26px;
|
|
||||||
}
|
|
||||||
.filter-select:focus {
|
|
||||||
border-color: var(--candle-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.filter-toggle input {
|
|
||||||
accent-color: var(--candle-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-count {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- TAGS DRAWER ---- */
|
/* ---- TAGS DRAWER ---- */
|
||||||
.tags-drawer-toggle {
|
|
||||||
padding: 8px 24px;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-btn {
|
.drawer-btn {
|
||||||
|
margin-left: auto;
|
||||||
font-family: "Commit Mono", monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
|
@ -579,7 +504,7 @@ onMounted(async () => {
|
||||||
.mc-avatar {
|
.mc-avatar {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: var(--surface);
|
background: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -720,15 +645,12 @@ onMounted(async () => {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
}
|
}
|
||||||
.filter-count {
|
.drawer-btn {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
.skills-bar {
|
.skills-bar {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
.tags-drawer-toggle {
|
|
||||||
padding: 8px 20px;
|
|
||||||
}
|
|
||||||
.active-filters {
|
.active-filters {
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
}
|
}
|
||||||
|
|
@ -748,14 +670,5 @@ onMounted(async () => {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.filter-select {
|
|
||||||
flex: 1 1 45%;
|
|
||||||
}
|
|
||||||
.filter-toggle {
|
|
||||||
flex: 1 1 45%;
|
|
||||||
}
|
|
||||||
.filter-count {
|
|
||||||
flex-basis: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ test.describe('Events list page', () => {
|
||||||
|
|
||||||
test('past events toggle exists and can be checked', async ({ page }) => {
|
test('past events toggle exists and can be checked', async ({ page }) => {
|
||||||
await page.goto('/events')
|
await page.goto('/events')
|
||||||
const checkbox = page.locator('input[type="checkbox"]')
|
await page.waitForLoadState('networkidle')
|
||||||
await expect(checkbox).toBeVisible()
|
const toggle = page.locator('.past-toggle')
|
||||||
await expect(page.locator('text=Show past events')).toBeVisible()
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(toggle).toContainText('Show past events')
|
||||||
|
|
||||||
await checkbox.check()
|
await toggle.click()
|
||||||
await expect(checkbox).toBeChecked()
|
await expect(toggle).toHaveClass(/active/)
|
||||||
|
|
||||||
// Page should still render without errors after toggling
|
// Page should still render without errors after toggling
|
||||||
await expect(page.locator('h1', { hasText: 'Events' })).toBeVisible()
|
await expect(page.locator('h1', { hasText: 'Events' })).toBeVisible()
|
||||||
|
|
@ -44,7 +45,7 @@ test.describe('Events list page', () => {
|
||||||
await page.goto('/events')
|
await page.goto('/events')
|
||||||
|
|
||||||
// Check the past events toggle so we see all events
|
// Check the past events toggle so we see all events
|
||||||
await page.locator('input[type="checkbox"]').check()
|
await page.locator('.past-toggle').click()
|
||||||
|
|
||||||
const eventLinks = page.locator('.event-row a')
|
const eventLinks = page.locator('.event-row a')
|
||||||
const count = await eventLinks.count()
|
const count = await eventLinks.count()
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,8 @@ test.describe('Join page — member signup flow', () => {
|
||||||
await page.locator('#join-name').fill('E2E Test User')
|
await page.locator('#join-name').fill('E2E Test User')
|
||||||
await page.locator('#join-email').fill(uniqueEmail)
|
await page.locator('#join-email').fill(uniqueEmail)
|
||||||
await page.locator('#circle-community').check({ force: true })
|
await page.locator('#circle-community').check({ force: true })
|
||||||
await page.locator('#join-contribution').selectOption('0')
|
await page.locator('#join-contribution').click()
|
||||||
|
await page.getByRole('option', { name: '$0/mo' }).click()
|
||||||
|
|
||||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||||
|
|
||||||
|
|
@ -108,7 +109,8 @@ test.describe('Join page — member signup flow', () => {
|
||||||
await page.locator('#join-name').fill('Dup Test User')
|
await page.locator('#join-name').fill('Dup Test User')
|
||||||
await page.locator('#join-email').fill(duplicateEmail)
|
await page.locator('#join-email').fill(duplicateEmail)
|
||||||
await page.locator('#circle-community').check({ force: true })
|
await page.locator('#circle-community').check({ force: true })
|
||||||
await page.locator('#join-contribution').selectOption('0')
|
await page.locator('#join-contribution').click()
|
||||||
|
await page.getByRole('option', { name: '$0/mo' }).click()
|
||||||
await page.locator('.form-submit').click()
|
await page.locator('.form-submit').click()
|
||||||
|
|
||||||
// Should show an error about the email already existing
|
// Should show an error about the email already existing
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ const authenticatedPages = [
|
||||||
{ name: 'admin-events-create', path: '/admin/events/create' },
|
{ name: 'admin-events-create', path: '/admin/events/create' },
|
||||||
// New authenticated pages
|
// New authenticated pages
|
||||||
{ name: 'member-account', path: '/member/account' },
|
{ name: 'member-account', path: '/member/account' },
|
||||||
{ name: 'member-activity', path: '/member/activity' },
|
|
||||||
{ name: 'connections', path: '/connections' },
|
{ name: 'connections', path: '/connections' },
|
||||||
{ name: 'admin-dashboard', path: '/admin' },
|
{ name: 'admin-dashboard', path: '/admin' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,9 @@ export default defineEventHandler(async (event) => {
|
||||||
// Complete ticket purchase (updates sold/reserved counts)
|
// Complete ticket purchase (updates sold/reserved counts)
|
||||||
await completeTicketPurchase(eventData, ticketInfo.ticketType);
|
await completeTicketPurchase(eventData, ticketInfo.ticketType);
|
||||||
|
|
||||||
// Save event with registration
|
// Save event with registration; skip validators to avoid tripping on
|
||||||
await eventData.save();
|
// legacy location data unrelated to this write.
|
||||||
|
await eventData.save({ validateBeforeSave: false });
|
||||||
|
|
||||||
// Send confirmation email
|
// Send confirmation email
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,6 @@ const memberSchema = new mongoose.Schema({
|
||||||
|
|
||||||
notifications: {
|
notifications: {
|
||||||
events: { type: Boolean, default: true },
|
events: { type: Boolean, default: true },
|
||||||
updates: { type: Boolean, default: true },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
inviteEmailSent: { type: Boolean, default: false },
|
inviteEmailSent: { type: Boolean, default: false },
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ export const memberProfileUpdateSchema = z.object({
|
||||||
}).optional(),
|
}).optional(),
|
||||||
showInDirectory: z.boolean().optional(),
|
showInDirectory: z.boolean().optional(),
|
||||||
notifications: z.object({
|
notifications: z.object({
|
||||||
events: z.boolean().optional(),
|
events: z.boolean().optional()
|
||||||
updates: z.boolean().optional()
|
|
||||||
}).optional(),
|
}).optional(),
|
||||||
craftTags: z.array(z.string().max(100)).max(16).optional(),
|
craftTags: z.array(z.string().max(100)).max(16).optional(),
|
||||||
boardSlackHandle: z.string().max(200).optional()
|
boardSlackHandle: z.string().max(200).optional()
|
||||||
|
|
@ -416,3 +415,12 @@ export const adminAlertRestoreSchema = z.object({
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(ADMIN_ALERT_TYPES.length)
|
.max(ADMIN_ALERT_TYPES.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Site content (key/value editable copy blocks) ---
|
||||||
|
|
||||||
|
export const SITE_CONTENT_KEYS = ['homepage.wiki_feature']
|
||||||
|
|
||||||
|
export const siteContentUpsertSchema = z.object({
|
||||||
|
title: z.string().max(300).optional(),
|
||||||
|
body: z.string().max(5000).optional()
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ export const releaseTicket = async (event, ticketType) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await event.save();
|
await event.save({ validateBeforeSave: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -309,7 +309,7 @@ export const completeTicketPurchase = async (event, ticketType) => {
|
||||||
event.tickets.public.sold = (event.tickets.public.sold || 0) + 1;
|
event.tickets.public.sold = (event.tickets.public.sold || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
await event.save();
|
await event.save({ validateBeforeSave: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue