Tests, UX improvements.

This commit is contained in:
Jennie Robinson Faber 2026-04-05 14:25:29 +01:00
parent 4e6f5d36b8
commit 0ae18f495e
63 changed files with 1384 additions and 2330 deletions

View file

@ -167,7 +167,7 @@ const youItems = [
{ label: "Dashboard", path: "/member/dashboard" },
{ label: "Profile", path: "/member/profile" },
{ label: "Account", path: "/member/account" },
{ label: "My Updates", path: "/member/my-updates" },
{ label: "Activity Log", path: "/member/activity" },
];
const exploreItems = [

View file

@ -0,0 +1,46 @@
<template>
<div class="sidebar-layout">
<div class="sidebar-layout-main">
<slot />
</div>
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</template>
<script setup>
const props = defineProps({
limit: { type: Number, default: 3 },
})
const { data: upcomingEvents } = await useFetch('/api/events', {
query: { limit: props.limit, upcoming: true },
default: () => [],
})
</script>
<style scoped>
.sidebar-layout {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.sidebar-layout-main {
min-width: 0;
align-self: stretch;
display: flex;
flex-direction: column;
min-height: 0;
}
@media (max-width: 1024px) {
.sidebar-layout {
grid-template-columns: 1fr;
}
}
</style>
```
Now let me apply this to each page. Let me update all four in parallel:

View file

@ -1,191 +0,0 @@
<template>
<UCard variant="outline" class="update-card">
<div class="flex gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
<img
v-if="update.author?.avatar"
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
:alt="update.author.name"
class="w-12 h-12 rounded-full"
@error="handleImageError"
/>
<div
v-else
class="w-12 h-12 rounded-full bg-guild-700 flex items-center justify-center text-guild-300 font-bold"
>
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h3 class="font-semibold text-guild-100">
<NuxtLink
v-if="update.author?._id"
:to="`/updates/user/${update.author._id}`"
class="hover:text-guild-300 transition-colors"
>
{{ update.author.name }}
</NuxtLink>
<span v-else>Unknown Member</span>
</h3>
<div class="flex items-center gap-2 text-sm text-guild-400">
<time :datetime="update.createdAt">
{{ formatDate(update.createdAt) }}
</time>
<span v-if="isEdited" class="text-guild-500">(edited)</span>
<span
v-if="update.privacy === 'private'"
class="px-2 py-0.5 bg-guild-700 text-guild-300 rounded text-xs"
>
Private
</span>
<span
v-if="update.privacy === 'public'"
class="px-2 py-0.5 bg-guild-700 text-guild-300 rounded text-xs"
>
Public
</span>
</div>
</div>
<!-- Actions (for author only) -->
<div v-if="isAuthor" class="flex gap-2">
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-edit"
aria-label="Edit update"
@click="$emit('edit', update)"
/>
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-trash-2"
aria-label="Delete update"
@click="$emit('delete', update)"
/>
</div>
</div>
<!-- Content -->
<div class="text-guild-200 whitespace-pre-wrap break-words mb-3">
<template v-if="showPreview && update.content.length > 300">
{{ update.content.substring(0, 300) }}...
<NuxtLink
:to="`/updates/${update._id}`"
class="text-guild-400 hover:text-guild-300 ml-1"
>
Read more
</NuxtLink>
</template>
<template v-else>
{{ update.content }}
</template>
</div>
<!-- Images (if any) -->
<div v-if="update.images?.length" class="mb-3 space-y-2">
<img
v-for="(image, index) in update.images"
:key="index"
:src="image.url"
:alt="image.alt || 'Update image'"
class="rounded-lg max-w-full h-auto"
/>
</div>
<!-- Footer actions -->
<div class="flex items-center gap-4 text-sm text-guild-400">
<NuxtLink
:to="`/updates/${update._id}`"
class="hover:text-guild-300 transition-colors"
>
View full update
</NuxtLink>
<span v-if="update.commentsEnabled" class="text-guild-500">
Comments (coming soon)
</span>
</div>
</div>
</div>
</UCard>
</template>
<script setup>
const props = defineProps({
update: {
type: Object,
required: true,
},
showPreview: {
type: Boolean,
default: true,
},
});
defineEmits(["edit", "delete"]);
const { memberData } = useAuth();
const isAuthor = computed(() => {
return memberData.value && props.update.author?._id === memberData.value.id;
});
const isEdited = computed(() => {
const created = new Date(props.update.createdAt).getTime();
const updated = new Date(props.update.updatedAt).getTime();
return updated - created > 1000; // More than 1 second difference
});
const capitalize = (str) => {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
};
const handleImageError = (e) => {
e.target.src = "/ghosties/Ghost-Mild.png"; // Fallback ghost
};
const formatDate = (date) => {
const now = new Date();
const updateDate = new Date(date);
const diffInSeconds = Math.floor((now - updateDate) / 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 updateDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
updateDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
</script>
<style scoped>
.update-card {
background-color: var(--color-guild-800);
border-color: var(--color-guild-600);
}
.update-card:hover {
border-color: var(--color-guild-500);
}
:deep(.card) {
background-color: var(--color-guild-800);
}
</style>

View file

@ -1,184 +0,0 @@
<template>
<div class="space-y-6">
<UFormField label="What's on your mind?" name="content" required>
<UTextarea
v-model="formData.content"
placeholder="Share your thoughts, updates, questions, or learnings with the community..."
:rows="8"
autoresize
:maxrows="20"
/>
</UFormField>
<!-- Privacy Settings -->
<div class="border border-guild-700 rounded-lg p-4 bg-guild-800/30">
<h3 class="text-sm font-medium text-guild-200 mb-4">Privacy Settings</h3>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="public"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Public</div>
<div class="text-sm text-guild-400">
Visible to everyone, including non-members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="members"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Members Only</div>
<div class="text-sm text-guild-400">
Only visible to Ghost Guild members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="private"
class="w-4 h-4 text-guild-400"
/>
<div>
<div class="text-guild-200 font-medium">Private</div>
<div class="text-sm text-guild-400">Only visible to you</div>
</div>
</label>
</div>
</div>
<!-- Image Upload (Future) -->
<!-- TODO: Add image upload integration with Cloudinary -->
<!-- Comments Toggle -->
<div class="flex items-center gap-3">
<USwitch v-model="formData.commentsEnabled" />
<div>
<div class="text-guild-200 font-medium">Enable Comments</div>
<div class="text-sm text-guild-400">
Allow members to comment on this update
</div>
</div>
</div>
<!-- Actions -->
<div
class="flex justify-between items-center pt-4 border-t border-guild-700"
>
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
Cancel
</UButton>
<UButton
:loading="submitting"
:disabled="!formData.content.trim()"
@click="handleSubmit"
>
{{ submitLabel }}
</UButton>
</div>
<!-- Error Message -->
<div
v-if="error"
class="bg-ember-900/20 border border-ember-400/30 rounded-lg p-4"
>
<p class="text-ember-400">{{ error }}</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
initialData: {
type: Object,
default: () => ({
content: "",
privacy: "members",
commentsEnabled: true,
images: [],
}),
},
submitLabel: {
type: String,
default: "Post Update",
},
submitting: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
});
const emit = defineEmits(["submit", "cancel"]);
const formData = reactive({
content: props.initialData.content || "",
privacy: props.initialData.privacy || "members",
commentsEnabled: props.initialData.commentsEnabled ?? true,
images: props.initialData.images || [],
});
const handleSubmit = () => {
if (!formData.content.trim()) return;
emit("submit", { ...formData });
};
// Watch for initialData changes (for edit mode)
watch(
() => props.initialData,
(newData) => {
if (newData) {
formData.content = newData.content || "";
formData.privacy = newData.privacy || "members";
formData.commentsEnabled = newData.commentsEnabled ?? true;
formData.images = newData.images || [];
}
},
{ immediate: true },
);
</script>
<style scoped>
/* Field labels */
:deep(label) {
color: var(--color-guild-200) !important;
font-weight: 500;
}
/* Textarea styling */
:deep(textarea) {
background-color: var(--color-guild-800) !important;
color: var(--color-guild-200) !important;
border-color: var(--color-guild-600) !important;
}
:deep(textarea::placeholder) {
color: var(--color-guild-500) !important;
}
:deep(textarea:focus) {
border-color: var(--color-guild-400) !important;
background-color: var(--color-guild-700) !important;
}
/* Radio buttons */
input[type="radio"] {
accent-color: var(--color-candlelight-600);
}
</style>

View file

@ -2,9 +2,10 @@
<div class="site">
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:p-3 focus:bg-[var(--bg)] focus:text-[var(--text)]"
class="sr-only focus:not-sr-only focus:absolute focus:z-100 focus:p-3 focus:bg-(--bg) focus:text-(--text)"
>Skip to content</a
>
<!-- Desktop Sidebar -->
<aside class="sidebar sidebar-desktop">
<NuxtLink to="/" class="sidebar-brand">Ghost Guild</NuxtLink>

View file

@ -31,8 +31,7 @@
</div>
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
<div class="content-area">
<div class="content-main">
<SidebarLayout>
<!-- THE CIRCLES -->
<div class="about-section" id="circles">
<div class="section-label">The Circles</div>
@ -79,9 +78,7 @@
<li>
<span class="tier-amt">$15</span> I can sustain the community
</li>
<li>
<span class="tier-amt">$30</span> I can support others too
</li>
<li><span class="tier-amt">$30</span> I can support others too</li>
<li>
<span class="tier-amt">$50</span> I want to sponsor multiple
members
@ -105,8 +102,8 @@
<div class="section-label">About Baby Ghosts</div>
<p>
Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit
advancing cooperative models in game development. No tracking. No
ads. No venture capital.
advancing cooperative models in game development. No tracking. No ads.
No venture capital.
</p>
<p>
<a href="https://babyghosts.fund" target="_blank"
@ -114,20 +111,11 @@
>
</p>
</div>
</div>
<!-- EVENTS MINI SIDEBAR -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</SidebarLayout>
</div>
</template>
<script setup>
const { data: upcomingEvents } = await useFetch("/api/events", {
query: { limit: 3, upcoming: true },
default: () => [],
});
</script>
<script setup></script>
<style scoped>
/* Flex chain from layout .main-body: hero + grid grow so sidebar column matches main height */
@ -176,24 +164,6 @@ const { data: upcomingEvents } = await useFetch("/api/events", {
margin-bottom: 10px;
}
/* ---- CONTENT AREA ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {
padding: 0;
min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
/* ---- SECTIONS ---- */
.about-section {
padding: 28px 32px;
@ -283,9 +253,6 @@ const { data: upcomingEvents } = await useFetch("/api/events", {
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
.circles-grid {
grid-template-columns: 1fr;
}

View file

@ -115,11 +115,83 @@
</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>
<!-- 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>
</div>
</div>
<div v-else class="loading-state">
No activity recorded.
</div>
</ClientOnly>
</section>
</template>
</div>
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
definePageMeta({
layout: "admin",
middleware: "admin",
@ -225,6 +297,50 @@ function statusClass(status) {
if (status === "cancelled" || status === "suspended") return "status-error";
return "status-dim";
}
// Activity log
const activityEntries = ref([])
const activityLoading = ref(false)
const activityLoadingMore = ref(false)
const activityHasMore = ref(false)
const activityNextCursor = ref(null)
const getActivity = (entry) => formatActivity(entry)
async function loadActivity() {
activityLoading.value = true
try {
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
params: { limit: 20 }
})
activityEntries.value = data.entries
activityHasMore.value = data.hasMore
activityNextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load activity:', err)
} finally {
activityLoading.value = false
}
}
async function loadMoreActivity() {
if (!activityNextCursor.value) return
activityLoadingMore.value = true
try {
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
params: { limit: 20, before: activityNextCursor.value }
})
activityEntries.value.push(...data.entries)
activityHasMore.value = data.hasMore
activityNextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load more activity:', err)
} finally {
activityLoadingMore.value = false
}
}
onMounted(loadActivity)
</script>
<style scoped>
@ -381,6 +497,62 @@ function statusClass(status) {
}
}
/* ---- ACTIVITY LOG ---- */
.activity-log {
margin-top: 12px;
border: 1px dashed var(--border);
}
.al-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.al-item:last-child {
border-bottom: none;
}
.al-admin {
opacity: 0.7;
}
.al-icon {
width: 14px;
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
}
.al-text {
flex: 1;
min-width: 0;
color: var(--text-dim);
}
.al-time {
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
}
.al-vis-badge {
font-size: 9px;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 4px;
flex-shrink: 0;
}
.al-load-more {
display: flex;
justify-content: center;
padding: 12px;
}
@media (max-width: 768px) {
.admin-member-detail {
padding: 20px 16px 40px;

View file

@ -12,7 +12,7 @@
<!-- FILTER BAR -->
<FilterBar v-model="activeFilter" :filters="filterOptions">
<label class="filter-toggle">
<input type="checkbox" v-model="includePastEvents" /> Show past events
<input v-model="includePastEvents" type="checkbox" /> Show past events
</label>
</FilterBar>

View file

@ -19,8 +19,7 @@
/>
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
<div class="content-area">
<div class="page-content">
<SidebarLayout>
<div class="account-columns">
<!-- LEFT COLUMN: Membership Status & Email -->
<div class="account-col-left">
@ -78,10 +77,7 @@
<div v-if="!showEmailEdit" class="email-display">
<span class="email-value">{{ memberData.email }}</span>
<button
class="btn btn-inline"
@click="showEmailEdit = true"
>
<button class="btn btn-inline" @click="showEmailEdit = true">
Change
</button>
</div>
@ -127,8 +123,8 @@
<div class="section-label danger">Danger Zone</div>
<div class="danger-zone">
<p>
Cancelling your membership will immediately revoke access
to member-only resources, events, and the Slack workspace.
Cancelling your membership will immediately revoke access to
member-only resources, events, and the Slack workspace.
<strong>This action cannot be easily undone.</strong>
</p>
<div v-if="showCancelConfirm" class="cancel-confirm">
@ -175,8 +171,8 @@
class="btn btn-primary btn-section"
@click="handleUpdateTier"
:disabled="
selectedTier ===
Number(memberData.contributionTier || 0) || isUpdating
selectedTier === Number(memberData.contributionTier || 0) ||
isUpdating
"
>
{{ isUpdating ? "Updating…" : "Update Contribution" }}
@ -195,9 +191,7 @@
<button
class="btn btn-primary btn-section"
@click="handleUpdateCircle"
:disabled="
selectedCircle === memberData.circle || isUpdating
"
:disabled="selectedCircle === memberData.circle || isUpdating"
>
{{ isUpdating ? "Updating…" : "Update Circle" }}
</button>
@ -205,11 +199,7 @@
</section>
</div>
</div>
</div>
<!-- EVENTS MINI SIDEBAR -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</SidebarLayout>
</div>
</div>
</template>
@ -281,11 +271,6 @@ watchEffect(() => {
}
});
const { data: upcomingEvents } = await useFetch("/api/events", {
query: { limit: 3, upcoming: true },
default: () => [],
});
const formatMemberSince = (dateStr) => {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
@ -420,23 +405,6 @@ const confirmCancelMembership = async () => {
min-height: 0;
}
/* ---- CONTENT AREA ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.page-content {
min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
/* ---- TWO-COLUMN LAYOUT ---- */
.account-columns {
flex: 1;
@ -644,9 +612,6 @@ const confirmCancelMembership = async () => {
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
.account-columns {
grid-template-columns: 1fr;
}

View file

@ -0,0 +1,341 @@
<template>
<div class="activity-page">
<PageHeader
title="Activity Log"
subtitle="Your activity and milestones in the Guild"
/>
<SidebarLayout :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>
</SidebarLayout>
</div>
</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>
.activity-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- 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>

View file

@ -26,7 +26,7 @@
<!-- Dashboard Content -->
<template v-else>
<div class="dashboard-with-sidebar">
<SidebarLayout :limit="5">
<div class="dashboard-body">
<!-- Member Status Banner -->
<MemberStatusBanner />
@ -204,8 +204,7 @@
</div>
</div>
</div>
<EventsMiniSidebar :events="upcomingEvents" />
</div>
</SidebarLayout>
</template>
<template #fallback>
@ -229,11 +228,6 @@ const loadingEvents = ref(false);
const calendarLinkCopied = ref(false);
const showCalendarInstructions = ref(false);
const { data: upcomingEvents } = await useFetch("/api/events", {
query: { limit: 5, upcoming: true },
default: () => [],
});
// Calendar subscription URL
const calendarUrl = computed(() => {
const memberId = memberData.value?._id || memberData.value?.id;
@ -483,15 +477,6 @@ useHead({
color: var(--text-dim);
}
/* ---- CONTENT GRID ---- */
.dashboard-with-sidebar {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.dashboard-body {
flex: 1;
display: flex;
@ -722,17 +707,8 @@ useHead({
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.dashboard-with-sidebar {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.dashboard-with-sidebar {
grid-template-columns: 1fr;
}
.content-row {
grid-template-columns: 1fr;
}

View file

@ -1,600 +0,0 @@
<template>
<div class="my-updates-page">
<PageHeader
title="My Updates"
subtitle="Your activity and milestones in the Guild"
/>
<!-- Content Area: two-column with events mini sidebar -->
<div class="content-area">
<!-- Main Content -->
<div class="content-main">
<ClientOnly>
<!-- Stats + New Update row -->
<div v-if="isAuthenticated && !pending" class="stats-row">
<span class="stats-count">
<strong>{{ total }}</strong> {{ total === 1 ? 'update' : 'updates' }} posted
</span>
<NuxtLink to="/updates/new" class="btn btn-primary">+ New Update</NuxtLink>
</div>
<!-- Loading State -->
<div v-if="pending && !updates.length" class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading your updates...</p>
</div>
<!-- Unauthenticated State -->
<div v-else-if="!isAuthenticated" class="state-box">
<div class="state-icon">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<h2 class="state-heading">Sign in required</h2>
<p class="state-text">Please sign in to view your updates.</p>
<button
class="btn btn-primary"
@click="openLoginModal({ title: 'Sign in to view your updates', description: 'Enter your email to access your updates' })"
>
Sign In
</button>
</div>
<!-- Updates Timeline -->
<div v-else-if="updates.length" class="timeline-wrap">
<div class="timeline">
<div
v-for="update in updates"
:key="update._id"
class="tl-item"
>
<div class="tl-dot">&#9998;</div>
<div class="tl-time">{{ formatDate(update.createdAt) }}</div>
<div class="tl-text">
<NuxtLink :to="`/updates/${update._id}`" class="tl-title">
{{ getUpdateTitle(update) }}
</NuxtLink>
<span v-if="isEdited(update)" class="tl-edited">(edited)</span>
<span v-if="update.privacy === 'private'" class="badge">Private</span>
<span v-if="update.privacy === 'public'" class="badge">Public</span>
</div>
<div v-if="getUpdatePreview(update)" class="tl-detail">
{{ getUpdatePreview(update) }}
</div>
<!-- Images -->
<div v-if="update.images?.length" class="tl-images">
<img
v-for="(image, index) in update.images"
:key="index"
:src="image.url"
:alt="image.alt || 'Update image'"
class="tl-image"
/>
</div>
<!-- Actions -->
<div v-if="isAuthor(update)" class="tl-actions">
<button class="tl-action-btn" @click="handleEdit(update)">Edit</button>
<span class="tl-action-sep">&middot;</span>
<button class="tl-action-btn tl-action-danger" @click="handleDelete(update)">Delete</button>
</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">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<h2 class="state-heading">No updates yet</h2>
<p class="state-text">Share your first update with the community</p>
<NuxtLink to="/updates/new" class="btn btn-primary">+ Post Your First Update</NuxtLink>
</div>
<template #fallback>
<div class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading your updates...</p>
</div>
</template>
</ClientOnly>
</div>
<!-- Events Mini Sidebar -->
<EventsMiniSidebar :events="upcomingEvents" />
</div>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal-box">
<h3 class="modal-heading">Delete Update?</h3>
<p class="modal-text">Are you sure you want to delete this update? This action cannot be undone.</p>
<div class="modal-actions">
<button class="btn" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
const { openLoginModal } = useLoginModal()
const updates = ref([])
const pending = ref(false)
const loadingMore = ref(false)
const hasMore = ref(false)
const total = ref(0)
const showDeleteModal = ref(false)
const updateToDelete = ref(null)
const deleting = ref(false)
const upcomingEvents = ref([])
// Check if current user is the author of an update
const isAuthor = (update) => {
return memberData.value && update.author?._id === memberData.value.id
}
// Check if update was edited
const isEdited = (update) => {
const created = new Date(update.createdAt).getTime()
const updated = new Date(update.updatedAt).getTime()
return updated - created > 1000
}
// Extract a title from update content (first line or first ~60 chars)
const getUpdateTitle = (update) => {
if (!update.content) return 'Untitled update'
const firstLine = update.content.split('\n')[0]
if (firstLine.length <= 80) return firstLine
return firstLine.substring(0, 80) + '...'
}
// Get a preview of the update content (after the first line)
const getUpdatePreview = (update) => {
if (!update.content) return ''
const lines = update.content.split('\n')
if (lines.length <= 1 && update.content.length <= 80) return ''
// If the first line was truncated, show the full content as preview
if (lines.length <= 1) return ''
const rest = lines.slice(1).join(' ').trim()
if (!rest) return ''
return rest.length > 200 ? rest.substring(0, 200) + '...' : rest
}
// Format date with relative time
const formatDate = (date) => {
const now = new Date()
const updateDate = new Date(date)
const diffInSeconds = Math.floor((now - updateDate) / 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 updateDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: updateDate.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
})
}
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus()
if (!authenticated) {
openLoginModal({
title: 'Sign in to view your updates',
description: 'Enter your email to access your updates',
})
return
}
}
await Promise.all([loadUpdates(), loadUpcomingEvents()])
})
// Load updates
const loadUpdates = async () => {
pending.value = true
try {
const response = await $fetch('/api/updates/my-updates', {
params: { limit: 20, skip: 0 },
})
updates.value = response.updates
total.value = response.total
hasMore.value = response.hasMore
} catch (error) {
console.error('Failed to load updates:', error)
} finally {
pending.value = false
}
}
// Load upcoming events for sidebar
const loadUpcomingEvents = async () => {
try {
const response = await $fetch('/api/events', {
params: { limit: 3, upcoming: true },
})
upcomingEvents.value = response.events || response || []
} catch (error) {
console.error('Failed to load upcoming events:', error)
}
}
// Load more updates
const loadMore = async () => {
loadingMore.value = true
try {
const response = await $fetch('/api/updates/my-updates', {
params: { limit: 20, skip: updates.value.length },
})
updates.value.push(...response.updates)
hasMore.value = response.hasMore
} catch (error) {
console.error('Failed to load more updates:', error)
} finally {
loadingMore.value = false
}
}
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`)
}
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update
showDeleteModal.value = true
}
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return
deleting.value = true
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: 'DELETE',
})
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
)
total.value--
showDeleteModal.value = false
updateToDelete.value = null
} catch (error) {
console.error('Failed to delete update:', error)
alert('Failed to delete update. Please try again.')
} finally {
deleting.value = false
}
}
useHead({
title: 'My Updates - Ghost Guild',
})
</script>
<style scoped>
.my-updates-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- TWO-COLUMN LAYOUT ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {
min-width: 0;
}
/* ---- STATS ROW ---- */
.stats-row {
padding: 16px 32px;
border-bottom: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.stats-count {
color: var(--text-dim);
}
.stats-count strong {
color: var(--text-bright);
font-size: 18px;
}
/* ---- STATE BOXES (loading, empty, unauth) ---- */
.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);
}
.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: 11px;
color: var(--text-dim);
}
.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: 8px;
flex-wrap: wrap;
}
.tl-title {
color: var(--text-bright);
text-decoration: none;
font-weight: 500;
}
.tl-title:hover {
color: var(--candle);
text-decoration: underline;
}
.tl-edited {
font-size: 11px;
color: var(--text-faint);
}
.tl-detail {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
padding: 8px 12px;
border-left: 2px solid var(--border);
line-height: 1.6;
}
.tl-images {
margin-top: 8px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tl-image {
max-width: 200px;
height: auto;
border: 1px dashed var(--border);
}
.tl-actions {
margin-top: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.tl-action-btn {
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-action-btn:hover {
color: var(--candle);
}
.tl-action-danger:hover {
color: var(--ember);
}
.tl-action-sep {
color: var(--border);
font-size: 10px;
}
/* ---- LOAD MORE ---- */
.load-more {
display: flex;
justify-content: center;
padding-top: 8px;
}
/* ---- MODAL ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(42, 32, 21, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-box {
background: var(--bg);
border: 1px dashed var(--border);
padding: 28px 32px;
max-width: 400px;
width: 90%;
}
.modal-heading {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 8px;
}
.modal-text {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.content-area {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.stats-row {
padding: 12px 20px;
}
.timeline-wrap {
padding: 20px 20px 40px;
}
.state-box {
padding: 48px 20px;
}
}
</style>

View file

@ -180,6 +180,18 @@
</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>
<!-- Auth Notice -->
<div v-if="!isAuthenticated" class="auth-notice">
<p>Sign in to see full profile details</p>
@ -206,6 +218,8 @@
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
const route = useRoute();
const { isAuthenticated } = useAuth();
const { openLoginModal } = useLoginModal();
@ -231,6 +245,26 @@ const getInitials = (name) => {
// Fetch member data no await so the component renders immediately (no Suspense)
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
// Fetch public activity
const { data: activityData } = useFetch(`/api/members/${id}/activity`, {
params: { limit: 5 },
default: () => ({ entries: [] })
})
const activityEntries = computed(() => activityData.value?.entries || [])
const getActivity = (entry) => formatActivity(entry)
const formatRelativeDate = (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)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
const member = computed(() => data.value?.member || null);
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
@ -467,6 +501,39 @@ useHead({
margin-bottom: 6px;
}
/* ---- ACTIVITY ---- */
.activity-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.activity-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.activity-icon {
width: 14px;
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
}
.activity-text {
color: var(--text-dim);
flex: 1;
min-width: 0;
}
.activity-time {
color: var(--text-faint);
font-size: 11px;
flex-shrink: 0;
}
/* ---- AUTH NOTICE ---- */
.auth-notice {
padding: 20px;

View file

@ -1,157 +0,0 @@
<template>
<div>
<div class="back-link">
<NuxtLink to="/member/my-updates">&larr; Back to My Updates</NuxtLink>
</div>
<div v-if="pending" class="loading">Loading update...</div>
<template v-else-if="update">
<div class="form-header">
<h1>Edit Update</h1>
</div>
<form @submit.prevent="handleSubmit" class="update-form">
<div class="field">
<label class="section-label">Content</label>
<textarea
v-model="form.content"
rows="8"
required
:disabled="saving"
></textarea>
<div class="char-count">{{ form.content.length }} / 50000</div>
</div>
<div class="field">
<label class="section-label">Visibility</label>
<select v-model="form.privacy" :disabled="saving">
<option value="members">Members only</option>
<option value="public">Public</option>
<option value="private">Private (only you)</option>
</select>
</div>
<div class="form-actions">
<NuxtLink to="/member/my-updates" class="btn">Cancel</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="saving || !form.content.trim()"
>
{{ saving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
</template>
<div v-else class="loading">Update not found.</div>
</div>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const route = useRoute()
const toast = useToast()
const { data: update, pending } = await useFetch(`/api/updates/${route.params.id}`)
const form = ref({
content: update.value?.content || '',
privacy: update.value?.privacy || 'members',
})
watch(update, (val) => {
if (val) {
form.value.content = val.content || ''
form.value.privacy = val.privacy || 'members'
}
})
const saving = ref(false)
const handleSubmit = async () => {
saving.value = true
try {
await $fetch(`/api/updates/${route.params.id}`, {
method: 'PATCH',
body: { content: form.value.content, privacy: form.value.privacy },
})
toast.add({ title: 'Update saved!', color: 'green' })
navigateTo('/member/my-updates')
} catch (err) {
toast.add({
title: 'Failed to save update',
description: err.data?.statusMessage || 'Please try again.',
color: 'red',
})
} finally {
saving.value = false
}
}
useHead({ title: 'Edit Update - Ghost Guild' })
</script>
<style scoped>
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.loading {
padding: 48px 32px;
font-size: 12px;
color: var(--text-dim);
}
.form-header {
padding: 28px 32px 20px;
border-bottom: 1px dashed var(--border);
}
.form-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 600;
color: var(--text-bright);
}
.update-form {
padding: 24px 32px;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 20px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
textarea, select {
font-family: 'Commit Mono', monospace;
font-size: 13px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text);
padding: 10px 12px;
resize: vertical;
width: 100%;
}
textarea:focus, select:focus { outline: none; border-color: var(--candle); }
textarea:disabled, select:disabled { opacity: 0.6; }
.char-count {
font-size: 11px;
color: var(--text-faint);
text-align: right;
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 4px;
}
</style>

View file

@ -1,139 +0,0 @@
<template>
<div>
<div class="back-link">
<NuxtLink to="/member/my-updates">&larr; Back to My Updates</NuxtLink>
</div>
<div class="form-header">
<h1>New Update</h1>
<p>Share what you're working on with the community</p>
</div>
<form @submit.prevent="handleSubmit" class="update-form">
<div class="field">
<label class="section-label">Content</label>
<textarea
v-model="form.content"
rows="8"
required
placeholder="What's on your mind? Share a project update, milestone, or thought..."
:disabled="saving"
></textarea>
<div class="char-count">{{ form.content.length }} / 50000</div>
</div>
<div class="field">
<label class="section-label">Visibility</label>
<select v-model="form.privacy" :disabled="saving">
<option value="members">Members only</option>
<option value="public">Public</option>
<option value="private">Private (only you)</option>
</select>
</div>
<div class="form-actions">
<NuxtLink to="/member/my-updates" class="btn">Cancel</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="saving || !form.content.trim()"
>
{{ saving ? 'Posting...' : 'Post Update' }}
</button>
</div>
</form>
</div>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const toast = useToast()
const form = ref({
content: '',
privacy: 'members',
})
const saving = ref(false)
const handleSubmit = async () => {
saving.value = true
try {
await $fetch('/api/updates', {
method: 'POST',
body: { content: form.value.content, privacy: form.value.privacy },
})
toast.add({ title: 'Update posted!', color: 'green' })
navigateTo('/member/my-updates')
} catch (err) {
toast.add({
title: 'Failed to post update',
description: err.data?.statusMessage || 'Please try again.',
color: 'red',
})
} finally {
saving.value = false
}
}
useHead({ title: 'New Update - Ghost Guild' })
</script>
<style scoped>
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.form-header {
padding: 28px 32px 0;
border-bottom: 1px dashed var(--border);
padding-bottom: 20px;
}
.form-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 4px;
}
.form-header p { font-size: 12px; color: var(--text-dim); }
.update-form {
padding: 24px 32px;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 20px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
textarea, select {
font-family: 'Commit Mono', monospace;
font-size: 13px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text);
padding: 10px 12px;
resize: vertical;
width: 100%;
}
textarea:focus, select:focus { outline: none; border-color: var(--candle); }
textarea:disabled, select:disabled { opacity: 0.6; }
.char-count {
font-size: 11px;
color: var(--text-faint);
text-align: right;
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 4px;
}
</style>

93
app/utils/activityText.js Normal file
View file

@ -0,0 +1,93 @@
const circleLabel = (c) => c ? c.charAt(0).toUpperCase() + c.slice(1) : c
const formatters = {
member_joined: (m) => ({
text: 'Joined Ghost Guild',
icon: 'i-lucide-star'
}),
event_registered: (m) => ({
text: `Registered for ${m.eventTitle || 'an event'}`,
icon: 'i-lucide-calendar',
link: m.eventSlug ? `/events/${m.eventSlug}` : null,
linkText: m.eventTitle
}),
event_cancelled: (m) => ({
text: `Cancelled registration for ${m.eventTitle || 'an event'}`,
icon: 'i-lucide-calendar-x',
link: m.eventSlug ? `/events/${m.eventSlug}` : null,
linkText: m.eventTitle
}),
event_waitlisted: (m) => ({
text: `Joined waitlist for ${m.eventTitle || 'an event'}`,
icon: 'i-lucide-calendar-clock',
link: null,
linkText: null
}),
peer_support_enabled: (m) => ({
text: m.topics?.length
? `Enabled peer support (${m.topics.join(', ')})`
: 'Enabled peer support',
icon: 'i-lucide-users'
}),
peer_support_disabled: () => ({
text: 'Disabled peer support',
icon: 'i-lucide-users'
}),
circle_changed: (m) => ({
text: `Changed circle from ${circleLabel(m.from)} to ${circleLabel(m.to)}`,
icon: 'i-lucide-refresh-cw'
}),
contribution_changed: (m) => ({
text: `Changed contribution from $${m.from}/mo to $${m.to}/mo`,
icon: 'i-lucide-coins'
}),
email_changed: (m) => ({
text: `Changed email address`,
icon: 'i-lucide-mail'
}),
profile_updated: (m) => ({
text: m.fields?.length
? `Updated profile (${m.fields.join(', ')})`
: 'Updated profile',
icon: 'i-lucide-user-pen'
}),
subscription_created: (m) => ({
text: m.tier ? `Started $${m.tier}/mo subscription` : 'Started subscription',
icon: 'i-lucide-credit-card'
}),
subscription_cancelled: () => ({
text: 'Cancelled subscription',
icon: 'i-lucide-credit-card'
}),
status_changed: (m) => ({
text: `Status changed from ${m.from} to ${m.to}${m.reason ? ` (${m.reason})` : ''}`,
icon: 'i-lucide-shield'
}),
role_changed: (m) => ({
text: `Role changed from ${m.from} to ${m.to}`,
icon: 'i-lucide-shield'
}),
admin_profile_update: (m) => ({
text: m.fields?.length
? `Profile updated by admin (${m.fields.join(', ')})`
: 'Profile updated by admin',
icon: 'i-lucide-user-pen'
}),
slack_invited: (m) => ({
text: `Slack invitation: ${m.status || 'sent'}`,
icon: 'i-lucide-message-square'
}),
email_sent: (m) => ({
text: m.subject ? `Email: ${m.subject}` : 'Email sent',
icon: 'i-lucide-mail',
emailBody: m.body || null
})
}
export function formatActivity(entry) {
const formatter = formatters[entry.type]
if (!formatter) {
return { text: entry.type.replace(/_/g, ' '), icon: 'i-lucide-activity' }
}
return formatter(entry.metadata || {})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View file

@ -1,123 +0,0 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('My Updates page', () => {
test('authenticated user sees the my-updates page', async ({ adminPage }) => {
await adminPage.goto('/member/my-updates')
await expect(adminPage.locator('h1', { hasText: 'My Updates' })).toBeVisible({
timeout: 10000,
})
})
test('authenticated user sees the new update link', async ({ adminPage }) => {
await adminPage.goto('/member/my-updates')
// Wait for ClientOnly content to hydrate
await expect(adminPage.locator('h1', { hasText: 'My Updates' })).toBeVisible({
timeout: 10000,
})
// The page shows either the "+ New Update" button (stats row) or
// the "+ Post Your First Update" link (empty state) — both go to /updates/new
const newUpdateLink = adminPage.locator('a[href="/updates/new"]')
await expect(newUpdateLink.first()).toBeVisible({ timeout: 10000 })
})
test('unauthenticated user sees sign-in prompt', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/member/my-updates')
await expect(
page
.getByText('Sign in required')
.or(page.getByText('Sign in to view your updates'))
).toBeVisible({ timeout: 10000 })
await context.close()
})
})
test.describe('New Update page', () => {
test('loads the new update form', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
// Form elements are present
await expect(adminPage.locator('textarea')).toBeVisible()
await expect(adminPage.locator('select')).toBeVisible()
// Submit button exists and starts disabled (empty textarea)
const submitBtn = adminPage.locator('button[type="submit"]')
await expect(submitBtn).toBeVisible()
await expect(submitBtn).toBeDisabled()
})
test('submit button enables when content is entered', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const textarea = adminPage.locator('textarea')
const submitBtn = adminPage.locator('button[type="submit"]')
await expect(submitBtn).toBeDisabled()
await textarea.fill('Test update content')
await expect(submitBtn).toBeEnabled()
})
test('privacy selector defaults to members and has all options', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const select = adminPage.locator('select')
await expect(select).toHaveValue('members')
// Verify all three privacy options exist
await expect(select.locator('option[value="members"]')).toBeAttached()
await expect(select.locator('option[value="public"]')).toBeAttached()
await expect(select.locator('option[value="private"]')).toBeAttached()
})
test('cancel link navigates back to my-updates', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const cancelLink = adminPage.locator('a', { hasText: 'Cancel' })
await expect(cancelLink).toHaveAttribute('href', '/member/my-updates')
})
test('back link points to my-updates', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
const backLink = adminPage.locator('.back-link a')
await expect(backLink).toBeVisible({ timeout: 10000 })
await expect(backLink).toHaveAttribute('href', '/member/my-updates')
})
})
test.describe('Updates API (public access)', () => {
test('public updates endpoint returns data', async ({ page }) => {
const response = await page.request.get('/api/updates')
expect(response.ok()).toBe(true)
const data = await response.json()
expect(data).toHaveProperty('updates')
expect(data).toHaveProperty('total')
expect(data).toHaveProperty('hasMore')
expect(Array.isArray(data.updates)).toBe(true)
})
})

View file

@ -2,7 +2,7 @@ import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const admin = await requireAdmin(event)
const body = await validateBody(event, adminMemberUpdateSchema)
const memberId = getRouterParam(event, 'id')
@ -30,6 +30,29 @@ export default defineEventHandler(async (event) => {
status: body.status,
}, { new: true })
// Log admin profile update
const changedFields = []
if (existing.name !== body.name) changedFields.push('name')
if (existing.email !== body.email) changedFields.push('email')
if (existing.circle !== body.circle) changedFields.push('circle')
if (existing.contributionTier !== body.contributionTier) changedFields.push('contributionTier')
if (existing.status !== body.status) changedFields.push('status')
if (changedFields.length) {
logActivity(memberId, 'admin_profile_update', {
fields: changedFields,
changedBy: admin.name
}, { performedBy: admin._id })
}
// Log status change separately for admin-only visibility
if (existing.status !== body.status) {
logActivity(memberId, 'status_changed', {
from: existing.status,
to: body.status
}, { performedBy: admin._id })
}
return {
_id: updated._id,
name: updated.name,

View file

@ -0,0 +1,28 @@
import ActivityLog from '../../../../models/activityLog.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
const query = getQuery(event)
const limit = Math.min(parseInt(query.limit) || 20, 50)
const before = query.before ? new Date(query.before) : null
const filter = { member: id }
if (before) filter.timestamp = { $lt: before }
const entries = await ActivityLog.find(filter)
.sort({ timestamp: -1 })
.limit(limit + 1)
.lean()
const hasMore = entries.length > limit
if (hasMore) entries.pop()
const nextCursor = hasMore && entries.length
? entries[entries.length - 1].timestamp.toISOString()
: null
return { entries, hasMore, nextCursor }
})

View file

@ -18,18 +18,25 @@ export default defineEventHandler(async (event) => {
})
}
const member = await Member.findByIdAndUpdate(
memberId,
{ role },
{ new: true }
)
if (!member) {
const existing = await Member.findById(memberId)
if (!existing) {
throw createError({
statusCode: 404,
statusMessage: 'Member not found.'
})
}
const oldRole = existing.role
const member = await Member.findByIdAndUpdate(
memberId,
{ role },
{ new: true }
)
logActivity(memberId, 'role_changed', {
from: oldRole,
to: role
}, { performedBy: admin._id })
return { success: true, member }
})

View file

@ -100,6 +100,12 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
)
logActivity(member._id, 'email_sent', {
emailType: 'invite',
subject: "You're invited to Ghost Guild",
body: emailText
})
results.push({ memberId: member._id, email: member.email, success: true })
} catch (err) {
results.push({ memberId: member._id, email: member.email, success: false, error: err.message })

View file

@ -52,19 +52,23 @@ export default defineEventHandler(async (event) => {
// Token goes in the fragment — never sent to server, never logged
const magicLink = `${baseUrl}/verify#${token}`;
const emailSubject = "Your Ghost Guild login link";
const emailBody = `Hi,\n\nSign in to Ghost Guild:\n${magicLink}\n\nThis link expires in 15 minutes. If you didn't request it, ignore this email.`;
try {
await resend.emails.send({
from: "Ghost Guild <ghostguild@babyghosts.org>",
to: email,
subject: "Your Ghost Guild login link",
text: `Hi,
Sign in to Ghost Guild:
${magicLink}
This link expires in 15 minutes. If you didn't request it, ignore this email.`,
subject: emailSubject,
text: emailBody,
});
logActivity(member._id, 'email_sent', {
emailType: 'magic_link',
subject: emailSubject,
body: emailBody
})
return {
success: true,
message: GENERIC_MESSAGE,

View file

@ -1,4 +1,5 @@
import Event from "../../../models/event";
import Member from "../../../models/member";
import {
sendEventCancellationEmail,
sendWaitlistNotificationEmail,
@ -56,19 +57,40 @@ export default defineEventHandler(async (event) => {
$pull: { registrations: { email: registration.email } },
$inc: { registeredCount: -1 },
},
{ runValidators: false }
{ runValidators: false },
);
// Send cancellation confirmation email
// Log activity + send cancellation confirmation email
const cancellingMember = await Member.findOne({
email: registration.email,
}).lean();
if (cancellingMember) {
logActivity(cancellingMember._id, 'event_cancelled', {
eventId: eventDoc._id,
eventTitle: eventDoc.title,
eventSlug: eventDoc.slug
})
}
try {
const shouldSendCancellation =
!cancellingMember || cancellingMember.notifications?.events !== false;
if (shouldSendCancellation) {
const eventData = {
title: eventDoc.title,
slug: eventDoc.slug,
_id: eventDoc._id,
};
await sendEventCancellationEmail(registration, eventData);
if (cancellingMember) {
logActivity(cancellingMember._id, 'email_sent', {
emailType: 'event_cancellation',
subject: `Registration cancelled for ${eventDoc.title}`
})
}
}
} catch (emailError) {
// Log error but don't fail the cancellation
console.error("Failed to send cancellation email:", emailError);
}
@ -89,18 +111,30 @@ export default defineEventHandler(async (event) => {
// Notify the first person on the waitlist who hasn't been notified yet
const waitlistEntry = eventDoc.tickets.waitlist.entries.find(
(entry) => !entry.notified
(entry) => !entry.notified,
);
if (waitlistEntry) {
const waitlistedMember = await Member.findOne({
email: waitlistEntry.email,
}).lean();
const shouldNotifyWaitlist =
!waitlistedMember ||
waitlistedMember.notifications?.events !== false;
if (shouldNotifyWaitlist) {
await sendWaitlistNotificationEmail(waitlistEntry, eventData);
// Mark as notified using findByIdAndUpdate to avoid re-validating the document
const entryIndex = eventDoc.tickets.waitlist.entries.indexOf(waitlistEntry);
}
// Always mark as notified so we move on regardless
const entryIndex =
eventDoc.tickets.waitlist.entries.indexOf(waitlistEntry);
await Event.findByIdAndUpdate(
eventDoc._id,
{ $set: { [`tickets.waitlist.entries.${entryIndex}.notified`]: true } },
{ runValidators: false }
{
$set: {
[`tickets.waitlist.entries.${entryIndex}.notified`]: true,
},
},
{ runValidators: false },
);
}
} catch (waitlistError) {

View file

@ -113,17 +113,36 @@ export default defineEventHandler(async (event) => {
const result = await Event.findByIdAndUpdate(
eventData._id,
{ $push: { registrations: registration } },
{ new: true, runValidators: false }
{ new: true, runValidators: false },
);
const newRegistration = result.registrations[result.registrations.length - 1];
const newRegistration =
result.registrations[result.registrations.length - 1];
// Send confirmation email using Resend
// Log activity
if (member) {
logActivity(member._id, 'event_registered', {
eventId: eventData._id,
eventTitle: eventData.title,
eventSlug: eventData.slug
})
}
// Send confirmation email — respect member notification preferences
const shouldSendEventEmail =
!member || member.notifications?.events !== false;
if (shouldSendEventEmail) {
try {
await sendEventRegistrationEmail(registration, eventData);
if (member) {
logActivity(member._id, 'email_sent', {
emailType: 'event_registration',
subject: `You're registered for ${eventData.title}`
})
}
} catch (emailError) {
// Log error but don't fail the registration
console.error("Failed to send confirmation email:", emailError);
}
}
return {
success: true,

View file

@ -91,6 +91,8 @@ export default defineEventHandler(async (event) => {
{ new: true }
)
logActivity(member._id, 'subscription_created', { tier: body.contributionTier })
// Send Slack invitation for free tier members
await inviteToSlack(member)
@ -262,6 +264,8 @@ export default defineEventHandler(async (event) => {
{ new: true }
)
logActivity(member._id, 'subscription_created', { tier: body.contributionTier })
// Send Slack invitation for paid tier members
await inviteToSlack(member)

View file

@ -0,0 +1,43 @@
import Member from '../../../models/member.js'
import ActivityLog from '../../../models/activityLog.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await connectDB()
const id = getRouterParam(event, 'id')
const member = await Member.findOne({
_id: id,
showInDirectory: true,
status: 'active'
}).lean()
if (!member) {
throw createError({ statusCode: 404, statusMessage: 'Member not found' })
}
const query = getQuery(event)
const limit = Math.min(parseInt(query.limit) || 5, 20)
const before = query.before ? new Date(query.before) : null
const filter = {
member: member._id,
visibility: 'public'
}
if (before) filter.timestamp = { $lt: before }
const entries = await ActivityLog.find(filter)
.sort({ timestamp: -1 })
.limit(limit + 1)
.lean()
const hasMore = entries.length > limit
if (hasMore) entries.pop()
const nextCursor = hasMore && entries.length
? entries[entries.length - 1].timestamp.toISOString()
: null
return { entries, hasMore, nextCursor }
})

View file

@ -62,6 +62,10 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logActivity(member._id, 'subscription_cancelled', {
effectiveDate: new Date().toISOString()
})
return {
success: true,
message: "Subscription cancelled successfully",

View file

@ -101,6 +101,11 @@ export default defineEventHandler(async (event) => {
const member = new Member(validatedData)
await member.save()
// Log member joined
logActivity(member._id, 'member_joined', {
circle: member.circle
}, { timestamp: member.createdAt })
// Send Slack invitation for new members
await inviteToSlack(member)
@ -112,6 +117,10 @@ export default defineEventHandler(async (event) => {
// Send welcome email (non-blocking)
try {
await sendWelcomeEmail(member)
logActivity(member._id, 'email_sent', {
emailType: 'welcome',
subject: 'Welcome to Ghost Guild'
})
} catch (emailError) {
console.error('Failed to send welcome email:', emailError)
}

View file

@ -0,0 +1,29 @@
import ActivityLog from '../../../models/activityLog.js'
export default defineEventHandler(async (event) => {
const member = await requireAuth(event)
const query = getQuery(event)
const limit = Math.min(parseInt(query.limit) || 20, 50)
const before = query.before ? new Date(query.before) : null
const filter = {
member: member._id,
visibility: { $in: ['member', 'public'] }
}
if (before) filter.timestamp = { $lt: before }
const entries = await ActivityLog.find(filter)
.sort({ timestamp: -1 })
.limit(limit + 1)
.lean()
const hasMore = entries.length > limit
if (hasMore) entries.pop()
const nextCursor = hasMore && entries.length
? entries[entries.length - 1].timestamp.toISOString()
: null
return { entries, hasMore, nextCursor }
})

View file

@ -75,6 +75,14 @@ export default defineEventHandler(async (event) => {
})
}
if (body.enabled) {
logActivity(member._id, 'peer_support_enabled', {
topics: [...(body.skillTopics || []), ...(body.supportTopics || [])]
})
} else {
logActivity(member._id, 'peer_support_disabled', {})
}
return {
success: true,
peerSupport: updated.peerSupport,

View file

@ -80,6 +80,12 @@ export default defineEventHandler(async (event) => {
});
}
// Log which fields were updated
const changedFields = Object.keys(body).filter(k => body[k] !== undefined && !k.endsWith('Privacy'))
if (changedFields.length) {
logActivity(memberId, 'profile_updated', { fields: changedFields })
}
// Return sanitized member data
return {
id: member._id,

View file

@ -13,12 +13,19 @@ export default defineEventHandler(async (event) => {
return { success: true, message: 'Already in this circle' }
}
const oldCircle = member.circle
await Member.findByIdAndUpdate(
member._id,
{ $set: { circle: body.circle } },
{ runValidators: false }
)
logActivity(member._id, 'circle_changed', {
from: oldCircle,
to: body.circle
})
return {
success: true,
message: `Circle updated to ${body.circle}`,

View file

@ -26,6 +26,11 @@ export default defineEventHandler(async (event) => {
};
}
// Log contribution change (fire-and-forget, at the top so it logs regardless of which case path executes)
const logContributionChange = () => {
logActivity(member._id, 'contribution_changed', { from: oldTier, to: newTier })
}
const helcimToken = config.helcimApiToken;
const oldRequiresPayment = requiresPayment(oldTier);
const newRequiresPayment = requiresPayment(newTier);
@ -160,6 +165,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully upgraded to paid tier",
@ -216,6 +223,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully downgraded to free tier",
@ -279,6 +288,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully updated contribution level",
@ -300,6 +311,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully updated contribution level",

View file

@ -62,6 +62,8 @@ export default defineEventHandler(async (event) => {
{ runValidators: false }
)
logActivity(member._id, 'email_changed', { previousEmail: oldEmail })
return {
success: true,
email: newEmail,

View file

@ -139,6 +139,12 @@ export default defineEventHandler(async (event) => {
})),
paymentId,
});
if (member) {
logActivity(member._id, 'email_sent', {
emailType: 'series_pass',
subject: `Series pass: ${series.title}`
})
}
} catch (emailError) {
console.error(
"Failed to send series pass confirmation email:",

View file

@ -1,38 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
const member = await requireAuth(event);
const memberId = member._id.toString();
const id = getRouterParam(event, "id");
try {
const update = await Update.findById(id);
if (!update) {
throw createError({
statusCode: 404,
statusMessage: "Update not found",
});
}
// Check if user is the author
if (update.author.toString() !== memberId) {
throw createError({
statusCode: 403,
statusMessage: "You can only delete your own updates",
});
}
await Update.findByIdAndDelete(id);
return { success: true };
} catch (error) {
if (error.statusCode) throw error;
console.error("Delete update error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to delete update",
});
}
});

View file

@ -1,51 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
let memberId = null
try {
const member = await requireAuth(event)
memberId = member._id.toString()
} catch {
// Not authenticated — continue with public-only access
}
try {
const update = await Update.findById(id).populate("author", "name avatar");
if (!update) {
throw createError({
statusCode: 404,
statusMessage: "Update not found",
});
}
// Check privacy permissions
if (update.privacy === "private") {
// Only author can view private updates
if (!memberId || update.author._id.toString() !== memberId) {
throw createError({
statusCode: 403,
statusMessage: "You don't have permission to view this update",
});
}
} else if (update.privacy === "members") {
// Must be authenticated to view members-only updates
if (!memberId) {
throw createError({
statusCode: 403,
statusMessage: "You must be a member to view this update",
});
}
}
return update;
} catch (error) {
if (error.statusCode) throw error;
console.error("Get update error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch update",
});
}
});

View file

@ -1,47 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
const member = await requireAuth(event);
const memberId = member._id.toString();
const id = getRouterParam(event, "id");
const body = await validateBody(event, updatePatchSchema);
try {
const update = await Update.findById(id);
if (!update) {
throw createError({
statusCode: 404,
statusMessage: "Update not found",
});
}
// Check if user is the author
if (update.author.toString() !== memberId) {
throw createError({
statusCode: 403,
statusMessage: "You can only edit your own updates",
});
}
// Update allowed fields
if (body.content !== undefined) update.content = body.content;
if (body.images !== undefined) update.images = body.images;
if (body.privacy !== undefined) update.privacy = body.privacy;
if (body.commentsEnabled !== undefined)
update.commentsEnabled = body.commentsEnabled;
await update.save();
await update.populate("author", "name avatar");
return update;
} catch (error) {
if (error.statusCode) throw error;
console.error("Update edit error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to update",
});
}
});

View file

@ -1,47 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
let memberId = null
try {
const member = await requireAuth(event)
memberId = member._id.toString()
} catch {
// Not authenticated — continue with public-only access
}
const query = getQuery(event);
const limit = parseInt(query.limit) || 20;
const skip = parseInt(query.skip) || 0;
try {
// Build privacy filter
let privacyFilter;
if (!memberId) {
// Not authenticated - only show public updates
privacyFilter = { privacy: "public" };
} else {
// Authenticated member - show public and members-only updates
privacyFilter = { privacy: { $in: ["public", "members"] } };
}
const updates = await Update.find(privacyFilter)
.populate("author", "name avatar")
.sort({ createdAt: -1 })
.limit(limit)
.skip(skip);
const total = await Update.countDocuments(privacyFilter);
return {
updates,
total,
hasMore: skip + limit < total,
};
} catch (error) {
console.error("Get updates error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch updates",
});
}
});

View file

@ -1,32 +0,0 @@
import Update from "../../models/update.js";
import { validateBody } from "../../utils/validateBody.js";
import { updateCreateSchema } from "../../utils/schemas.js";
export default defineEventHandler(async (event) => {
const member = await requireAuth(event);
const memberId = member._id.toString();
const body = await validateBody(event, updateCreateSchema);
try {
const update = await Update.create({
author: memberId,
content: body.content,
images: body.images || [],
privacy: body.privacy || "members",
commentsEnabled: body.commentsEnabled ?? true,
});
// Populate author details
await update.populate("author", "name avatar");
return update;
} catch (error) {
if (error.statusCode) throw error;
console.error("Create update error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to create update",
});
}
});

View file

@ -1,32 +0,0 @@
import Update from "../../models/update.js";
export default defineEventHandler(async (event) => {
const member = await requireAuth(event);
const memberId = member._id.toString();
const query = getQuery(event);
const limit = parseInt(query.limit) || 20;
const skip = parseInt(query.skip) || 0;
try {
const updates = await Update.find({ author: memberId })
.populate("author", "name avatar")
.sort({ createdAt: -1 })
.limit(limit)
.skip(skip);
const total = await Update.countDocuments({ author: memberId });
return {
updates,
total,
hasMore: skip + limit < total,
};
} catch (error) {
console.error("Get my updates error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch updates",
});
}
});

View file

@ -1,67 +0,0 @@
import Update from "../../../models/update.js";
import Member from "../../../models/member.js";
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, "id");
let currentMemberId = null
try {
const member = await requireAuth(event)
currentMemberId = member._id.toString()
} catch {
// Not authenticated — continue with public-only access
}
const query = getQuery(event);
const limit = parseInt(query.limit) || 20;
const skip = parseInt(query.skip) || 0;
try {
// Verify the user exists
const user = await Member.findById(userId);
if (!user) {
throw createError({
statusCode: 404,
statusMessage: "User not found",
});
}
// Build privacy filter
let privacyFilter;
if (!currentMemberId) {
// Not authenticated - only show public updates
privacyFilter = { author: userId, privacy: "public" };
} else if (currentMemberId === userId) {
// Viewing own updates - show all
privacyFilter = { author: userId };
} else {
// Authenticated member viewing another's updates - show public and members-only
privacyFilter = { author: userId, privacy: { $in: ["public", "members"] } };
}
const updates = await Update.find(privacyFilter)
.populate("author", "name avatar")
.sort({ createdAt: -1 })
.limit(limit)
.skip(skip);
const total = await Update.countDocuments(privacyFilter);
return {
updates,
total,
hasMore: skip + limit < total,
user: {
_id: user._id,
name: user.name,
avatar: user.avatar,
},
};
} catch (error) {
if (error.statusCode) throw error;
console.error("Get user updates error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch user updates",
});
}
});

View file

@ -0,0 +1,58 @@
import mongoose from 'mongoose'
const ACTIVITY_TYPES = [
'member_joined',
'event_registered',
'event_cancelled',
'event_waitlisted',
'peer_support_enabled',
'peer_support_disabled',
'circle_changed',
'contribution_changed',
'email_changed',
'profile_updated',
'subscription_created',
'subscription_cancelled',
'status_changed',
'role_changed',
'admin_profile_update',
'slack_invited',
'email_sent'
]
const activityLogSchema = new mongoose.Schema({
member: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Member',
required: true
},
type: {
type: String,
enum: ACTIVITY_TYPES,
required: true
},
visibility: {
type: String,
enum: ['member', 'admin', 'public'],
default: 'member'
},
metadata: {
type: mongoose.Schema.Types.Mixed,
default: () => ({})
},
performedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Member'
},
timestamp: {
type: Date,
default: Date.now
}
})
// Indexes
activityLogSchema.index({ member: 1, timestamp: -1 })
activityLogSchema.index({ member: 1, visibility: 1, timestamp: -1 })
activityLogSchema.index({ type: 1, timestamp: -1 })
export default mongoose.models.ActivityLog || mongoose.model('ActivityLog', activityLogSchema)

View file

@ -1,50 +0,0 @@
import mongoose from "mongoose";
const updateSchema = new mongoose.Schema({
author: {
type: mongoose.Schema.Types.ObjectId,
ref: "Member",
required: true,
},
content: {
type: String,
required: true,
},
images: [
{
url: String,
publicId: String,
alt: String,
},
],
privacy: {
type: String,
enum: ["public", "members", "private"],
default: "members",
},
commentsEnabled: {
type: Boolean,
default: true,
},
createdAt: {
type: Date,
default: Date.now,
},
updatedAt: {
type: Date,
default: Date.now,
},
});
// Update the updatedAt timestamp on save
updateSchema.pre("save", function (next) {
this.updatedAt = Date.now();
next();
});
// Indexes for performance
updateSchema.index({ createdAt: -1 }); // For sorting by date
updateSchema.index({ privacy: 1, createdAt: -1 }); // Compound index for filtering and sorting
updateSchema.index({ author: 1 }); // For author lookups
export default mongoose.models.Update || mongoose.model("Update", updateSchema);

View file

@ -0,0 +1,71 @@
import ActivityLog from '../models/activityLog.js'
export const ACTIVITY_TYPES = {
MEMBER_JOINED: 'member_joined',
EVENT_REGISTERED: 'event_registered',
EVENT_CANCELLED: 'event_cancelled',
EVENT_WAITLISTED: 'event_waitlisted',
PEER_SUPPORT_ENABLED: 'peer_support_enabled',
PEER_SUPPORT_DISABLED: 'peer_support_disabled',
CIRCLE_CHANGED: 'circle_changed',
CONTRIBUTION_CHANGED: 'contribution_changed',
EMAIL_CHANGED: 'email_changed',
PROFILE_UPDATED: 'profile_updated',
SUBSCRIPTION_CREATED: 'subscription_created',
SUBSCRIPTION_CANCELLED: 'subscription_cancelled',
STATUS_CHANGED: 'status_changed',
ROLE_CHANGED: 'role_changed',
ADMIN_PROFILE_UPDATE: 'admin_profile_update',
SLACK_INVITED: 'slack_invited',
EMAIL_SENT: 'email_sent'
}
export const ACTIVITY_TYPE_DEFAULTS = {
member_joined: 'public',
event_registered: 'public',
event_cancelled: 'member',
event_waitlisted: 'member',
peer_support_enabled: 'public',
peer_support_disabled: 'member',
circle_changed: 'member',
contribution_changed: 'member',
email_changed: 'member',
profile_updated: 'member',
subscription_created: 'member',
subscription_cancelled: 'member',
status_changed: 'admin',
role_changed: 'admin',
admin_profile_update: 'admin',
slack_invited: 'admin',
email_sent: 'member'
}
/**
* Log an activity for a member. Fire-and-forget catches errors
* and logs to console without blocking the request.
*
* @param {string|ObjectId} memberId
* @param {string} type - one of ACTIVITY_TYPES values
* @param {object} [metadata={}]
* @param {object} [options]
* @param {string|ObjectId} [options.performedBy] - admin who initiated the action
* @param {string} [options.visibility] - override default visibility
* @param {Date} [options.timestamp] - override Date.now (for backfill)
*/
export async function logActivity(memberId, type, metadata = {}, options = {}) {
try {
const visibility = options.visibility || ACTIVITY_TYPE_DEFAULTS[type] || 'member'
const doc = {
member: memberId,
type,
visibility,
metadata
}
if (options.performedBy) doc.performedBy = options.performedBy
if (options.timestamp) doc.timestamp = options.timestamp
return await ActivityLog.create(doc)
} catch (err) {
console.error(`[activityLog] Failed to log ${type} for member ${memberId}:`, err)
}
}

View file

@ -57,13 +57,6 @@ export const eventRegistrationSchema = z.object({
dietary: z.boolean().optional()
})
export const updateCreateSchema = z.object({
content: z.string().min(1).max(50000),
images: z.array(z.string().url()).max(20).optional(),
privacy: z.enum(['public', 'members', 'private']).optional(),
commentsEnabled: z.boolean().optional()
})
export const paymentVerifySchema = z.object({
cardToken: z.string().min(1),
customerId: z.union([z.string(), z.number()]).transform(String)
@ -179,15 +172,6 @@ export const peerSupportUpdateSchema = z.object({
slackUsername: z.string().max(200).optional()
})
// --- Update schemas ---
export const updatePatchSchema = z.object({
content: z.string().min(1).max(50000).optional(),
images: z.array(z.string().url()).max(20).optional(),
privacy: z.enum(['public', 'members', 'private']).optional(),
commentsEnabled: z.boolean().optional()
})
// --- Series ticket schemas ---
export const seriesTicketPurchaseSchema = z.object({

View file

@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
vi.mock('../../../server/models/member.js', () => ({
default: { findByIdAndUpdate: vi.fn() }
default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
@ -104,7 +104,7 @@ describe('admin role PATCH endpoint', () => {
describe('member not found', () => {
it('returns 404 when member does not exist', async () => {
Member.findByIdAndUpdate.mockResolvedValue(null)
Member.findById.mockResolvedValue(null)
const event = createMockEvent({
method: 'PATCH',
@ -122,7 +122,9 @@ describe('admin role PATCH endpoint', () => {
describe('successful role changes', () => {
it('promotes a member to admin', async () => {
validateBody.mockResolvedValue({ role: 'admin' })
const existingMember = { _id: 'target-member-id', role: 'member', name: 'Test User' }
const updatedMember = { _id: 'target-member-id', role: 'admin', name: 'Test User' }
Member.findById.mockResolvedValue(existingMember)
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
const event = createMockEvent({
@ -143,7 +145,9 @@ describe('admin role PATCH endpoint', () => {
it('demotes a member to regular role', async () => {
validateBody.mockResolvedValue({ role: 'member' })
const existingMember = { _id: 'target-member-id', role: 'admin', name: 'Test User' }
const updatedMember = { _id: 'target-member-id', role: 'member', name: 'Test User' }
Member.findById.mockResolvedValue(existingMember)
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
const event = createMockEvent({

View file

@ -1,112 +0,0 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const updatesDir = resolve(import.meta.dirname, '../../../server/api/updates')
describe('Updates API auth guards', () => {
describe('index.post.js (create)', () => {
const source = readFileSync(resolve(updatesDir, 'index.post.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
// The public GET routes wrap requireAuth in try/catch to make it optional.
// The create route must NOT do that — auth failure should halt the request.
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
// Check the line before requireAuth is not a try {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
})
describe('[id].patch.js (edit)', () => {
const source = readFileSync(resolve(updatesDir, '[id].patch.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
it('verifies ownership by comparing update.author with authenticated member ID', () => {
expect(source).toContain('update.author.toString() !== memberId')
})
it('throws 403 when user is not the author', () => {
expect(source).toContain('statusCode: 403')
})
})
describe('[id].delete.js (delete)', () => {
const source = readFileSync(resolve(updatesDir, '[id].delete.js'), 'utf-8')
it('requires auth via requireAuth(event)', () => {
expect(source).toContain('requireAuth(event)')
})
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
expect(authLine).toBeGreaterThan(-1)
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).not.toMatch(/try\s*\{/)
})
it('verifies ownership by comparing update.author with authenticated member ID', () => {
expect(source).toContain('update.author.toString() !== memberId')
})
it('throws 403 when user is not the author', () => {
expect(source).toContain('statusCode: 403')
})
})
describe('index.get.js (list — public)', () => {
const source = readFileSync(resolve(updatesDir, 'index.get.js'), 'utf-8')
it('does NOT enforce requireAuth (public access allowed)', () => {
// The route uses requireAuth inside a try/catch so unauthenticated
// users can still access it — auth failure is caught and ignored.
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
// If requireAuth is present, it must be wrapped in try/catch
if (authLine > -1) {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).toMatch(/try\s*\{/)
}
// Either way, the route must not throw on unauthenticated access
})
it('does not call requireAdmin', () => {
expect(source).not.toContain('requireAdmin')
})
})
describe('[id].get.js (get — public)', () => {
const source = readFileSync(resolve(updatesDir, '[id].get.js'), 'utf-8')
it('does NOT enforce requireAuth (public access allowed)', () => {
const lines = source.split('\n')
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
if (authLine > -1) {
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
expect(preceding).toMatch(/try\s*\{/)
}
})
it('does not call requireAdmin', () => {
expect(source).not.toContain('requireAdmin')
})
})
})

View file

@ -16,7 +16,6 @@ import {
eventPaymentSchema,
updateContributionSchema,
peerSupportUpdateSchema,
updatePatchSchema,
seriesTicketPurchaseSchema,
seriesTicketEligibilitySchema,
adminSeriesCreateSchema,
@ -329,28 +328,6 @@ describe('peerSupportUpdateSchema', () => {
})
})
// --- Update schemas ---
describe('updatePatchSchema', () => {
it('accepts valid update patch', () => {
const result = updatePatchSchema.safeParse({
content: 'Updated content',
privacy: 'members'
})
expect(result.success).toBe(true)
})
it('accepts empty object (all optional)', () => {
const result = updatePatchSchema.safeParse({})
expect(result.success).toBe(true)
})
it('rejects invalid privacy enum', () => {
const result = updatePatchSchema.safeParse({ privacy: 'invalid' })
expect(result.success).toBe(false)
})
})
// --- Series schemas ---
describe('seriesTicketPurchaseSchema', () => {
@ -529,7 +506,6 @@ describe('validateBody migration coverage', () => {
'events/[id]/payment.post.js',
'members/update-contribution.post.js',
'members/me/peer-support.patch.js',
'updates/[id].patch.js',
'series/[id]/tickets/purchase.post.js',
'series/[id]/tickets/check-eligibility.post.js',
'admin/series.post.js',

View file

@ -5,7 +5,6 @@ import {
memberCreateSchema,
memberProfileUpdateSchema,
eventRegistrationSchema,
updateCreateSchema,
paymentVerifySchema,
adminEventCreateSchema
} from '../../../server/utils/schemas.js'
@ -120,55 +119,6 @@ describe('eventRegistrationSchema', () => {
})
})
describe('updateCreateSchema', () => {
it('accepts valid content', () => {
const result = updateCreateSchema.safeParse({ content: 'Hello world' })
expect(result.success).toBe(true)
})
it('rejects empty content', () => {
const result = updateCreateSchema.safeParse({ content: '' })
expect(result.success).toBe(false)
})
it('rejects content exceeding 50000 chars', () => {
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50001) })
expect(result.success).toBe(false)
})
it('accepts content at exactly 50000 chars', () => {
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50000) })
expect(result.success).toBe(true)
})
it('validates images are URLs', () => {
const result = updateCreateSchema.safeParse({
content: 'test',
images: ['not-a-url']
})
expect(result.success).toBe(false)
})
it('accepts valid images array', () => {
const result = updateCreateSchema.safeParse({
content: 'test',
images: ['https://example.com/img.png']
})
expect(result.success).toBe(true)
})
it('rejects more than 20 images', () => {
const images = Array.from({ length: 21 }, (_, i) => `https://example.com/img${i}.png`)
const result = updateCreateSchema.safeParse({ content: 'test', images })
expect(result.success).toBe(false)
})
it('validates privacy enum', () => {
const result = updateCreateSchema.safeParse({ content: 'test', privacy: 'invalid' })
expect(result.success).toBe(false)
})
})
describe('paymentVerifySchema', () => {
it('accepts valid card token and customer ID', () => {
const result = paymentVerifySchema.safeParse({ cardToken: 'tok_123', customerId: 'cust_456' })

View file

@ -40,3 +40,4 @@ vi.stubGlobal('useRuntimeConfig', () => ({
vi.stubGlobal('requireAuth', vi.fn())
vi.stubGlobal('requireAdmin', vi.fn())
vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event)))
vi.stubGlobal('logActivity', vi.fn())