diff --git a/app/components/EventsMiniSidebar.vue b/app/components/EventsMiniSidebar.vue
index 1cf35ce..de6066d 100644
--- a/app/components/EventsMiniSidebar.vue
+++ b/app/components/EventsMiniSidebar.vue
@@ -7,9 +7,11 @@
{{ formatDate(event.startDate) }}
-
{{
- event.title
- }}
+
{{ event.title }}
ghostguild.org
/
- {{
- crumb.label
- }}
+ {{ crumb.label }}
+ {{ crumb.label }}
@@ -79,7 +83,8 @@ const breadcrumbs = computed(() => {
let path = "";
return segments.map((segment) => {
path += "/" + segment.replace(/\s+/g, "-");
- return { label: segment, path };
+ const label = segment.charAt(0).toUpperCase() + segment.slice(1);
+ return { label, path };
});
});
@@ -130,4 +135,7 @@ const breadcrumbs = computed(() => {
.breadcrumb-sep {
color: var(--text-faint);
}
+.breadcrumb-current {
+ color: var(--text-dim);
+}
diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue
index 7f1aa80..91d20b6 100644
--- a/app/layouts/admin.vue
+++ b/app/layouts/admin.vue
@@ -1,6 +1,10 @@
@@ -59,28 +72,49 @@
@@ -104,23 +148,29 @@
diff --git a/app/pages/events/[id].vue b/app/pages/events/[id].vue
deleted file mode 100644
index ffc2400..0000000
--- a/app/pages/events/[id].vue
+++ /dev/null
@@ -1,511 +0,0 @@
-
- Loading event details...
-
-
-
Event Not Found
-
The event you're looking for doesn't exist.
-
← Back to Events
-
-
-
-
-
- ← Back to Events
-
-
-
-
-
-
-
-
Event Cancelled
-
{{ event.cancellationMessage }}
-
This event has been cancelled. We apologize for any inconvenience.
-
-
-
-
-
-
-
-
-
- Part of Series
- {{ event.series.title }}
- — Event {{ event.series.position }} of {{ event.series.totalEvents }}
-
-
-
-
-
-
Recommended for
-
-
-
-
-
-
-
-
About This Event
-
{{ event.description }}
-
-
-
-
-
About the {{ event.series.title }} Series
-
{{ event.series.description }}
-
-
-
-
-
Agenda
-
- - {{ item }}
-
-
-
-
-
-
Speakers
-
-
{{ speaker.name }}
-
{{ speaker.role }}
-
{{ speaker.bio }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Registration
-
You're registered!
-
Confirmation sent to your email
-
-
-
-
-
-
Registration
-
{{ statusConfig.label }}
-
{{ getRSVPMessage() }}
-
-
-
-
-
-
-
-
Registration
-
Membership Required
-
This event is exclusive to members.
-
-
-
-
-
-
Registration
-
- {{ event.maxAttendees - (event.registeredCount || 0) }} spots remaining
-
-
Free for members
-
-
Add to calendar
-
-
-
-
-
-
-
-
Waitlist
-
-
You're on the waitlist (#{{ waitlistPosition }})
-
-
-
-
-
-
-
-
-
Event Details
-
- Type
- {{ event.eventType }}
-
-
- Members only
- Yes
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue
new file mode 100644
index 0000000..994fa0e
--- /dev/null
+++ b/app/pages/events/[slug].vue
@@ -0,0 +1,725 @@
+
+ Loading event details...
+
+
+
Event Not Found
+
The event you're looking for doesn't exist.
+
← Back to Events
+
+
+
+
+
+
+
+
+
Event Cancelled
+
{{ event.cancellationMessage }}
+
+ This event has been cancelled. We apologize for any inconvenience.
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+ Part of Series
+ {{
+ event.series.title
+ }}
+ — Event {{ event.series.position }} of
+ {{ event.series.totalEvents }}
+
+
+
+
+
+
Recommended for
+
+
+
+
+
+
+
+
About This Event
+
{{ event.description }}
+
+
+
+
+
About the {{ event.series.title }} Series
+
{{ event.series.description }}
+
+
+
+
+
Agenda
+
+ -
+ {{ item }}
+
+
+
+
+
+
+
Speakers
+
+
{{ speaker.name }}
+
+ {{ speaker.role }}
+
+
{{ speaker.bio }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Registration
+
+ You're registered!
+
+
Confirmation sent to your email
+
+
+
+
+
+
Registration
+
+ {{ statusConfig.label }}
+
+
{{ getRSVPMessage() }}
+
+
+
+
+
+
+
+
Registration
+
+ Membership Required
+
+
This event is exclusive to members.
+
+
+
+
+
+
Registration
+
+ {{ event.maxAttendees - (event.registeredCount || 0) }} spots
+ remaining
+
+
Free for members
+
+
Add to calendar
+
+
+
+
+
+
+
+
Waitlist
+
+
+ You're on the waitlist (#{{ waitlistPosition }})
+
+
+
+
+
+ This event is full
+
+
+
+
+
+
+
+
+
Event Details
+
+ Type
+ {{ event.eventType }}
+
+
+ Members only
+ Yes
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue
index 5bbf9de..586cc81 100644
--- a/app/pages/events/index.vue
+++ b/app/pages/events/index.vue
@@ -3,13 +3,16 @@
Events
-
Workshops, meetups, and gatherings for game developers practicing cooperative models.
+
+ Workshops, meetups, and gatherings for game developers practicing
+ cooperative models.
+
@@ -19,13 +22,31 @@
v-for="event in filteredEvents"
:key="event._id"
class="event-row"
+ :class="{ 'is-cancelled': event.isCancelled }"
>
- {{ formatDate(event.startDate) }}
+
+ {{ formatDate(event.startDate) }}
+ {{ formatTime(event.startDate) }}
+
- {{ event.title }}
+ {{
+ event.title
+ }}
+ cancelled
+
+
+ {{ event.tagline }}
+
+
+ {{
+ event.eventType
+ }}
+ ·
+ {{ formatLocation(event) }}
-
{{ event.eventType }}
@@ -35,8 +56,11 @@
Open
-
- All
+
+ Members
+
+ All
+
No events found
@@ -54,68 +78,111 @@
{{ series.title }}
{{ series.description }}
- {{ series.eventCount || series.events?.length || 0 }} sessions
- {{ formatDate(series.startDate) }} – {{ formatDate(series.endDate) }}
+ {{
+ series.eventCount || series.events?.length || 0
+ }}
+ sessions
+ {{ formatDate(series.startDate) }} –
+ {{ formatDate(series.endDate) }}
+
Have an idea?
Propose an Event
- Members can propose events for any circle. Workshops, social hangs, talks, or anything else that serves the community.
- Propose an event →
+
+ Members can propose events for any circle. Workshops, social hangs,
+ talks, or anything else that serves the community.
+
+ Propose an event → coming soon
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 2cba24e..9e0519c 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -49,7 +49,7 @@
{{ formatDate(event.startDate) }}
- {{
+ {{
event.title
}}
diff --git a/app/pages/members/[id].vue b/app/pages/members/[id].vue
index 4491279..b055b27 100644
--- a/app/pages/members/[id].vue
+++ b/app/pages/members/[id].vue
@@ -233,6 +233,18 @@ const getInitials = (name) => {
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
const member = computed(() => data.value?.member || null);
+const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
+watch(
+ member,
+ (val) => {
+ pageBreadcrumbTitle.value = val?.name || "";
+ },
+ { immediate: true },
+);
+onUnmounted(() => {
+ pageBreadcrumbTitle.value = "";
+});
+
// Page head
useHead({
title: computed(() =>
diff --git a/scripts/migrate-event-slugs.js b/scripts/migrate-event-slugs.js
index 0a5d877..c97e89a 100644
--- a/scripts/migrate-event-slugs.js
+++ b/scripts/migrate-event-slugs.js
@@ -1,66 +1,90 @@
-import mongoose from 'mongoose'
-import Event from '../server/models/event.js'
-import { connectDB } from '../server/utils/mongoose.js'
+import mongoose from "mongoose";
+import dotenv from "dotenv";
+import { connectDB } from "../server/utils/mongoose.js";
+import Event from "../server/models/event.js";
-// Generate slug from title
-function generateSlug(title) {
- return title
+dotenv.config();
+
+function generateSlug(title, startDate) {
+ const titleSlug = title
.toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '')
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+
+ if (startDate) {
+ const d = new Date(startDate);
+ const year = d.getUTCFullYear();
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(d.getUTCDate()).padStart(2, "0");
+ return `${year}-${month}-${day}-${titleSlug}`;
+ }
+
+ return titleSlug;
}
async function migrateEventSlugs() {
try {
- // Connect to database
- await connectDB()
- console.log('Connected to database')
+ await connectDB();
+ console.log("Connected to database\n");
- // Find all events without slugs
- const eventsWithoutSlugs = await Event.find({
- $or: [
- { slug: { $exists: false } },
- { slug: null },
- { slug: '' }
- ]
- })
+ const events = await Event.find({}).sort({ startDate: 1 }).lean();
+ console.log(`Found ${events.length} event(s) to process\n`);
- console.log(`Found ${eventsWithoutSlugs.length} events without slugs`)
-
- if (eventsWithoutSlugs.length === 0) {
- console.log('All events already have slugs!')
- return
+ if (events.length === 0) {
+ console.log("No events found.");
+ return;
}
- // Generate and assign unique slugs
- for (const event of eventsWithoutSlugs) {
- let baseSlug = generateSlug(event.title)
- let slug = baseSlug
- let counter = 1
+ // Build new slugs first so uniqueness checks don't conflict with slugs
+ // we're about to replace in the same run.
+ const updates = [];
+ const usedSlugs = new Set();
- // Ensure slug is unique
- while (await Event.findOne({ slug, _id: { $ne: event._id } })) {
- slug = `${baseSlug}-${counter}`
- counter++
+ for (const event of events) {
+ let baseSlug = generateSlug(event.title, event.startDate);
+ let slug = baseSlug;
+ let counter = 1;
+
+ // Ensure slug is unique within this batch and across the DB
+ // (excluding the current event's own existing slug)
+ while (
+ usedSlugs.has(slug) ||
+ (await Event.findOne({ slug, _id: { $ne: event._id } }).lean())
+ ) {
+ slug = `${baseSlug}-${counter}`;
+ counter++;
}
- event.slug = slug
- await event.save({ validateBeforeSave: false }) // Skip validation to avoid pre-save hook
- console.log(`✓ Generated slug "${slug}" for event "${event.title}"`)
+ usedSlugs.add(slug);
+ updates.push({ event, oldSlug: event.slug, newSlug: slug });
}
- console.log(`Successfully migrated ${eventsWithoutSlugs.length} events!`)
+ // Apply updates
+ let changed = 0;
+ let skipped = 0;
+
+ for (const { event, oldSlug, newSlug } of updates) {
+ if (oldSlug === newSlug) {
+ console.log(` — ${event.title}`);
+ console.log(` slug unchanged: ${newSlug}`);
+ skipped++;
+ } else {
+ // Use updateOne to bypass the pre-save hook (avoids re-triggering slug generation)
+ await Event.updateOne({ _id: event._id }, { $set: { slug: newSlug } });
+ console.log(` ✓ ${event.title}`);
+ console.log(` ${oldSlug || "(none)"} → ${newSlug}`);
+ changed++;
+ }
+ }
+
+ console.log(`\nDone. ${changed} updated, ${skipped} already correct.`);
} catch (error) {
- console.error('Error migrating event slugs:', error)
+ console.error("\nMigration failed:", error);
+ process.exit(1);
} finally {
- await mongoose.connection.close()
- console.log('Database connection closed')
+ await mongoose.connection.close();
+ console.log("Database connection closed.");
}
}
-// Run migration if called directly
-if (import.meta.url === `file://${process.argv[1]}`) {
- migrateEventSlugs()
-}
-
-export default migrateEventSlugs
\ No newline at end of file
+migrateEventSlugs();
diff --git a/server/api/admin/members/[id].get.js b/server/api/admin/members/[id].get.js
new file mode 100644
index 0000000..12fd54d
--- /dev/null
+++ b/server/api/admin/members/[id].get.js
@@ -0,0 +1,17 @@
+import Member from '../../../models/member.js'
+import { connectDB } from '../../../utils/mongoose.js'
+
+export default defineEventHandler(async (event) => {
+ await requireAdmin(event)
+
+ const memberId = getRouterParam(event, 'id')
+
+ await connectDB()
+
+ const member = await Member.findById(memberId).lean()
+ if (!member) {
+ throw createError({ statusCode: 404, statusMessage: 'Member not found' })
+ }
+
+ return member
+})
diff --git a/server/models/event.js b/server/models/event.js
index a914634..16245bc 100644
--- a/server/models/event.js
+++ b/server/models/event.js
@@ -187,20 +187,35 @@ const eventSchema = new mongoose.Schema({
updatedAt: { type: Date, default: Date.now },
});
-// Generate slug from title
-function generateSlug(title) {
- return title
+// Generate slug from title and startDate
+function generateSlug(title, startDate) {
+ const titleSlug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
+
+ if (startDate) {
+ const d = new Date(startDate);
+ const year = d.getUTCFullYear();
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(d.getUTCDate()).padStart(2, "0");
+ return `${year}-${month}-${day}-${titleSlug}`;
+ }
+
+ return titleSlug;
}
// Pre-save hook to generate slug
eventSchema.pre("save", async function (next) {
try {
- // Always generate slug if it doesn't exist or if title has changed
- if (!this.slug || this.isNew || this.isModified("title")) {
- let baseSlug = generateSlug(this.title);
+ // Always generate slug if it doesn't exist or if title/startDate has changed
+ if (
+ !this.slug ||
+ this.isNew ||
+ this.isModified("title") ||
+ this.isModified("startDate")
+ ) {
+ let baseSlug = generateSlug(this.title, this.startDate);
let slug = baseSlug;
let counter = 1;