Tests, UX improvements.
|
|
@ -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 = [
|
||||
|
|
|
|||
46
app/components/SidebarLayout.vue
Normal 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:
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
341
app/pages/member/activity.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">✎</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">·</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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="back-link">
|
||||
<NuxtLink to="/member/my-updates">← 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>
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="back-link">
|
||||
<NuxtLink to="/member/my-updates">← 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
|
|
@ -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 || {})
|
||||
}
|
||||
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
28
server/api/admin/members/[id]/activity.get.js
Normal 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 }
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
43
server/api/members/[id]/activity.get.js
Normal 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 }
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
29
server/api/members/me/activity.get.js
Normal 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 }
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ export default defineEventHandler(async (event) => {
|
|||
{ runValidators: false }
|
||||
)
|
||||
|
||||
logActivity(member._id, 'email_changed', { previousEmail: oldEmail })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
email: newEmail,
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
58
server/models/activityLog.js
Normal 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)
|
||||
|
|
@ -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);
|
||||
71
server/utils/activityLog.js
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||