feat: reskin admin pages to zine design system

Migrate the entire admin section from the dark guild-* Tailwind theme
to the zine design system (dashed borders, CSS custom properties,
Brygada 1918 + Commit Mono, cream/dark mode palette).

- Replace admin top-nav layout with sidebar matching default layout
- Reskin dashboard, members, events, series management pages
- Reskin events/create and series/create form pages
- Add dev-only test login endpoint (GET /api/dev/test-login)
- Redirect duplicate admin/dashboard.vue to /admin
- Update CLAUDE.md design system docs
This commit is contained in:
Jennie Robinson Faber 2026-04-03 10:56:01 +01:00
parent f16f9ada64
commit fcd6f4cdf4
23 changed files with 3845 additions and 3827 deletions

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ logs
.fleet .fleet
.idea .idea
/docs/ /docs/
*.md/
# Local env files # Local env files
.env .env

View file

@ -14,6 +14,8 @@ npm run build # Production build
npm run preview # Preview production build npm run preview # Preview production build
``` ```
**Dev helpers:** `GET /api/dev/test-login` — creates a test admin user and sets auth cookie (dev only, blocked in production). Navigate to this URL to access admin pages during development.
No test framework is currently configured. No test framework is currently configured.
## Architecture ## Architecture
@ -35,8 +37,8 @@ No test framework is currently configured.
- `app/composables/` — State management via `useState()` (no Pinia/Vuex). Key composables: `useAuth`, `useHelcim`, `useMemberPayment`, `useMemberStatus` - `app/composables/` — State management via `useState()` (no Pinia/Vuex). Key composables: `useAuth`, `useHelcim`, `useMemberPayment`, `useMemberStatus`
- `app/config/` — Circle definitions (`circles.js`) and contribution tiers (`contributions.js`) used across frontend and forms - `app/config/` — Circle definitions (`circles.js`) and contribution tiers (`contributions.js`) used across frontend and forms
- `app/middleware/` — Route guards: `auth.js` (member pages), `admin.js` (admin pages), `coming-soon.global.js` (launch gate) - `app/middleware/` — Route guards: `auth.js` (member pages), `admin.js` (admin pages), `coming-soon.global.js` (launch gate)
- `app/layouts/``default`, `admin`, `landing`, `coming-soon` - `app/layouts/``default` (sidebar, member/public), `admin` (sidebar, admin pages), `landing`, `coming-soon`
- `server/api/` — Nitro API routes organized by feature: `auth/`, `events/`, `members/`, `helcim/`, `series/`, `updates/`, `admin/`, `slack/` - `server/api/` — Nitro API routes organized by feature: `auth/`, `events/`, `members/`, `helcim/`, `series/`, `updates/`, `admin/`, `slack/`, `dev/` (dev-only helpers)
- `server/models/` — Mongoose schemas: `Member`, `Event`, `Series`, `Update` - `server/models/` — Mongoose schemas: `Member`, `Event`, `Series`, `Update`
- `server/utils/` — Service integrations: `mongoose.js`, `helcim.js`, `resend.js`, `slack.ts`, `tickets.js` - `server/utils/` — Service integrations: `mongoose.js`, `helcim.js`, `resend.js`, `slack.ts`, `tickets.js`
@ -48,14 +50,15 @@ Member statuses: `pending_payment`, `active`, `suspended`, `cancelled`.
Events support ticketing with circle-specific pricing overrides and can be grouped into Series with bundled passes. Events support ticketing with circle-specific pricing overrides and can be grouped into Series with bundled passes.
### Design System ### Design System (Zine Direction)
- **Colors:** `guild-*` (warm neutral), `candlelight-*` (amber/gold accent), `parchment-*` (cream surfaces), `ember-*` (rust accent), `earth-*` (brown/ochre) — defined in `app/assets/css/main.css` - **Palette:** CSS custom properties in `:root` / `.dark` blocks in `app/assets/css/main.css``--bg` (cream/#f4efe4), `--surface`, `--border`, `--candle` (gold accent), `--ember` (rust accent), `--text`, `--text-bright`, `--text-dim`, `--text-faint`, `--parch` (inverted blocks), `--c-community`, `--c-founder`, `--c-practitioner`
- **Circle tokens:** `--color-circle-community`, `--color-circle-founder`, `--color-circle-practitioner` with `-light`, `-dark`, `-bg` variants - **Typography:** Brygada 1918 (serif, display/headings) + Commit Mono (monospace, body/UI/everything structural) — loaded via Google Fonts in `nuxt.config.ts`
- **Typography:** Inter (body), Quietism (display/headers, self-hosted from `public/fonts/`), Ubuntu Mono (code) - **Theme:** `primary: amber`, `neutral: stone` — configured in `app/app.config.ts`. Tailwind `@theme` maps `--font-sans` and `--font-mono` to Commit Mono, `--font-display` to Brygada 1918
- **Theme:** `primary: amber`, `neutral: stone` — configured in `app/app.config.ts` - **Key classes:** `.btn` / `.btn-primary` / `.btn-danger` (buttons), `.field` (form groups), `.badge` (circle badges), `.section-label` (10px uppercase headers), `.dashed-box` (bordered containers), `.section-divider`
- **Effects:** `.candlelight-glow`, `.warm-text`, `.ink-grain`, `.paper-texture`, `.woodcut-border`, `.guild-stamp`, `.halftone-texture`, `.dithered-bg`, `.dithered-warm` - **Visual language:** Dashed borders (1px dashed), cream backgrounds, no rounded corners, text-forward density, minimal decoration
- **Content:** `.prose-guild` class for wiki/long-form content with warm palette and Quietism headings - **Color mode:** `@nuxtjs/color-mode` with preference `system`, fallback `light`. Dark mode via `.dark` class on `<html>`
- **Layouts:** `default` (sidebar + main, member/public pages), `admin` (sidebar + main, admin pages), `landing` (horizontal nav, unused)
### Environment ### Environment

View file

@ -5,8 +5,4 @@ export default defineAppConfig({
neutral: "stone", neutral: "stone",
}, },
}, },
colorMode: {
preference: "light",
fallback: "light",
},
}); });

View file

@ -29,6 +29,7 @@
--text-faint: #8a7e6a; --text-faint: #8a7e6a;
--parch: #2a2015; --parch: #2a2015;
--parch-text: #ede4d0; --parch-text: #ede4d0;
--parch-text-dim: #b8ae98;
--c-community: #7a4838; --c-community: #7a4838;
--c-founder: #8a4420; --c-founder: #8a4420;
--c-practitioner: #2a4650; --c-practitioner: #2a4650;
@ -52,6 +53,7 @@
--text-faint: #5a5040; --text-faint: #5a5040;
--parch: #ede4d0; --parch: #ede4d0;
--parch-text: #2a2015; --parch-text: #2a2015;
--parch-text-dim: #5a5040;
--c-community: #a06850; --c-community: #a06850;
--c-founder: #c06030; --c-founder: #c06030;
--c-practitioner: #4a7080; --c-practitioner: #4a7080;

View file

@ -1,62 +1,64 @@
<template> <template>
<div v-if="shouldShowBanner" class="w-full"> <ClientOnly>
<div <div v-if="shouldShowBanner" class="w-full">
:class="[ <div
'backdrop-blur-sm border rounded-lg p-4 flex items-start gap-4', :class="[
statusConfig.bgColor, 'backdrop-blur-sm border rounded-lg p-4 flex items-start gap-4',
statusConfig.borderColor, statusConfig.bgColor,
]" statusConfig.borderColor,
> ]"
<Icon >
:name="statusConfig.icon" <Icon
:class="['w-5 h-5 flex-shrink-0 mt-0.5', statusConfig.textColor]" :name="statusConfig.icon"
/> :class="['w-5 h-5 flex-shrink-0 mt-0.5', statusConfig.textColor]"
/>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 :class="['font-semibold mb-1', statusConfig.textColor]"> <h3 :class="['font-semibold mb-1', statusConfig.textColor]">
{{ statusConfig.label }} {{ statusConfig.label }}
</h3> </h3>
<p :class="['text-sm', statusConfig.textColor, 'opacity-90']"> <p :class="['text-sm', statusConfig.textColor, 'opacity-90']">
{{ bannerMessage }} {{ bannerMessage }}
</p> </p>
</div> </div>
<div class="flex items-center gap-2 flex-shrink-0"> <div class="flex items-center gap-2 flex-shrink-0">
<!-- Payment button for pending payment status --> <!-- Payment button for pending payment status -->
<UButton <UButton
v-if="isPendingPayment && nextAction" v-if="isPendingPayment && nextAction"
:color="getButtonColor(nextAction.color)" :color="getButtonColor(nextAction.color)"
size="sm" size="sm"
:loading="isProcessingPayment" :loading="isProcessingPayment"
@click="handleActionClick" @click="handleActionClick"
class="whitespace-nowrap" class="whitespace-nowrap"
> >
{{ isProcessingPayment ? "Processing..." : nextAction.label }} {{ isProcessingPayment ? "Processing..." : nextAction.label }}
</UButton> </UButton>
<!-- Link button for other actions --> <!-- Link button for other actions -->
<NuxtLink <NuxtLink
v-else-if="nextAction && nextAction.link" v-else-if="nextAction && nextAction.link"
:to="nextAction.link" :to="nextAction.link"
:class="[ :class="[
'px-4 py-2 rounded-lg font-medium text-sm whitespace-nowrap transition-all', 'px-4 py-2 rounded-lg font-medium text-sm whitespace-nowrap transition-all',
getActionButtonClass(nextAction.color), getActionButtonClass(nextAction.color),
]" ]"
> >
{{ nextAction.label }} {{ nextAction.label }}
</NuxtLink> </NuxtLink>
<button <button
v-if="dismissible" v-if="dismissible"
@click="isDismissed = true" @click="isDismissed = true"
class="text-guild-400 hover:text-guild-200 transition-colors" class="text-guild-400 hover:text-guild-200 transition-colors"
:aria-label="`Dismiss ${statusConfig.label} banner`" :aria-label="`Dismiss ${statusConfig.label} banner`"
> >
<Icon name="heroicons:x-mark" class="w-5 h-5" /> <Icon name="heroicons:x-mark" class="w-5 h-5" />
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </ClientOnly>
</template> </template>
<script setup> <script setup>

View file

@ -78,7 +78,7 @@ const props = defineProps({
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"', default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"',
}, },
inputClass: { inputClass: {
type: String, type: [String, Object],
default: "", default: "",
}, },
required: { required: {

View file

@ -23,7 +23,7 @@
.parchment-inset :deep(p) { .parchment-inset :deep(p) {
font-size: 13px; font-size: 13px;
color: #b8ae98; color: var(--parch-text-dim);
line-height: 1.75; line-height: 1.75;
max-width: 560px; max-width: 560px;
margin-bottom: 12px; margin-bottom: 12px;

View file

@ -5,15 +5,20 @@
</span> </span>
<span> <span>
<slot name="right"> <slot name="right">
<template v-if="memberData"> <ClientOnly>
Signed in as {{ memberData.name }} <template v-if="memberData">
<template v-if="memberData.circle"> Signed in as {{ memberData.name }}
&middot; {{ memberData.circle }} <template v-if="memberData.circle">
&middot; {{ memberData.circle }}
</template>
</template> </template>
</template> <template v-else>
<template v-else> A cooperative for game developers
A cooperative for game developers </template>
</template> <template #fallback>
A cooperative for game developers
</template>
</ClientOnly>
</slot> </slot>
</span> </span>
</div> </div>

View file

@ -1,350 +1,267 @@
<template> <template>
<div class="min-h-screen bg-guild-950"> <div class="site">
<!-- Admin Navigation --> <!-- Desktop Sidebar -->
<nav class="bg-guild-900 border-b border-guild-700 shadow-sm"> <aside class="sidebar sidebar-desktop">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <NuxtLink to="/" class="sidebar-brand">Ghost Guild</NuxtLink>
<div class="flex justify-between items-center py-4">
<div class="flex items-center gap-8"> <div class="sidebar-body">
<NuxtLink <div class="sidebar-section">Admin</div>
to="/" <ul class="sidebar-nav">
class="text-xl font-bold font-serif warm-text text-guild-100 hover:text-candlelight-400" <li>
> <NuxtLink to="/admin" :class="{ active: route.path === '/admin' }">
Ghost Guild Dashboard
</NuxtLink> </NuxtLink>
</li>
<li>
<NuxtLink to="/admin/members" :class="{ active: route.path.startsWith('/admin/members') }">
Members
</NuxtLink>
</li>
<li>
<NuxtLink to="/admin/events" :class="{ active: route.path.startsWith('/admin/events') }">
Events
</NuxtLink>
</li>
<li>
<NuxtLink to="/admin/series-management" :class="{ active: route.path.includes('/admin/series') }">
Series
</NuxtLink>
</li>
</ul>
<div class="hidden md:flex items-center gap-1"> <div class="sidebar-section">Site</div>
<NuxtLink <ul class="sidebar-nav">
to="/admin" <li><NuxtLink to="/member/dashboard">Your Dashboard</NuxtLink></li>
:class="[ <li><NuxtLink to="/">Public Site</NuxtLink></li>
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200', </ul>
$route.path === '/admin'
? 'bg-candlelight-900/30 text-candlelight-400 shadow-sm'
: 'text-guild-400 hover:text-candlelight-400 hover:bg-candlelight-900/10',
]"
>
<svg
class="w-4 h-4 inline-block mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4h4"
/>
</svg>
Dashboard
</NuxtLink>
<NuxtLink
to="/admin/members"
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/members')
? 'bg-candlelight-900/30 text-candlelight-400 shadow-sm'
: 'text-guild-400 hover:text-candlelight-400 hover:bg-candlelight-900/10',
]"
>
<svg
class="w-4 h-4 inline-block mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
Members
</NuxtLink>
<NuxtLink
to="/admin/events"
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/events')
? 'bg-candlelight-900/30 text-candlelight-400 shadow-sm'
: 'text-guild-400 hover:text-candlelight-400 hover:bg-candlelight-900/10',
]"
>
<svg
class="w-4 h-4 inline-block mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Events
</NuxtLink>
<NuxtLink
to="/admin/series-management"
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/series')
? 'bg-candlelight-900/30 text-candlelight-400 shadow-sm'
: 'text-guild-400 hover:text-candlelight-400 hover:bg-candlelight-900/10',
]"
>
<svg
class="w-4 h-4 inline-block mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Series
</NuxtLink>
</div>
</div>
<div class="flex items-center gap-4">
<!-- User Menu -->
<div
class="relative"
@click="showUserMenu = !showUserMenu"
v-click-outside="() => (showUserMenu = false)"
>
<button
class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-guild-800 cursor-pointer transition-colors"
>
<div
class="w-8 h-8 bg-candlelight-600 rounded-full flex items-center justify-center"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<span class="hidden md:block text-sm font-medium text-guild-100"
>Admin</span
>
<svg
class="w-4 h-4 text-guild-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- User Menu Dropdown -->
<div
v-if="showUserMenu"
class="absolute right-0 mt-2 w-56 bg-guild-800 rounded-lg shadow-lg border border-guild-700 py-1 z-50"
>
<NuxtLink
to="/"
class="flex items-center px-4 py-2 text-sm text-guild-100 hover:bg-guild-700"
>
<svg
class="w-4 h-4 mr-3 text-guild-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
View Site
</NuxtLink>
<NuxtLink
to="/admin/settings"
class="flex items-center px-4 py-2 text-sm text-guild-100 hover:bg-guild-700"
>
<svg
class="w-4 h-4 mr-3 text-guild-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</NuxtLink>
<hr class="my-1 border-guild-700" />
<button
@click="logout"
class="flex items-center w-full px-4 py-2 text-sm text-ember-400 hover:bg-ember-900/20"
>
<svg
class="w-4 h-4 mr-3 text-ember-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Logout
</button>
</div>
</div>
</div>
</div>
</div> </div>
</nav>
<!-- Mobile Navigation --> <div class="sidebar-meta">
<div class="md:hidden bg-guild-900 border-b border-guild-700"> <span class="admin-tag">admin</span><br>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <a href="#" @click.prevent="logout">Sign out</a>
<div class="flex items-center gap-2 py-3 overflow-x-auto">
<NuxtLink
to="/admin"
:class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path === '/admin'
? 'bg-candlelight-900/30 text-candlelight-400'
: 'text-guild-400 hover:text-candlelight-400 hover:bg-candlelight-900/10',
]"
>
Dashboard
</NuxtLink>
<NuxtLink
to="/admin/members"
:class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/members')
? 'bg-candlelight-900/30 text-candlelight-400'
: 'text-guild-400 hover:text-candlelight-400 hover:bg-candlelight-900/10',
]"
>
Members
</NuxtLink>
<NuxtLink
to="/admin/events"
:class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/events')
? 'bg-candlelight-900/30 text-candlelight-400'
: 'text-guild-400 hover:text-candlelight-400 hover:bg-candlelight-900/10',
]"
>
Events
</NuxtLink>
<NuxtLink
to="/admin/series-management"
:class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/series')
? 'bg-candlelight-900/30 text-candlelight-400'
: 'text-guild-400 hover:text-candlelight-400 hover:bg-candlelight-900/10',
]"
>
Series
</NuxtLink>
</div>
</div> </div>
</aside>
<!-- Mobile Header -->
<div class="mobile-header">
<NuxtLink to="/admin" class="brand">Ghost Guild</NuxtLink>
<button class="btn" @click="isMobileMenuOpen = true">Menu</button>
</div> </div>
<!-- Page Content --> <!-- Main Content -->
<main> <main class="main">
<TopStrip :page-path="currentPageName" />
<slot /> <slot />
</main> </main>
<!-- Footer --> <!-- Mobile Drawer -->
<footer class="bg-guild-900 border-t border-guild-700 mt-auto"> <USlideover v-model:open="isMobileMenuOpen" side="left">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <template #body>
<div class="py-8 text-center"> <aside class="sidebar sidebar-mobile">
<p class="text-sm text-guild-400"> <NuxtLink to="/" class="sidebar-brand" @click="isMobileMenuOpen = false">Ghost Guild</NuxtLink>
&copy; 2025 Ghost Guild. Admin Panel.
</p> <div class="sidebar-body">
</div> <div class="sidebar-section">Admin</div>
</div> <ul class="sidebar-nav">
</footer> <li>
<NuxtLink to="/admin" :class="{ active: route.path === '/admin' }" @click="isMobileMenuOpen = false">
Dashboard
</NuxtLink>
</li>
<li>
<NuxtLink to="/admin/members" :class="{ active: route.path.startsWith('/admin/members') }" @click="isMobileMenuOpen = false">
Members
</NuxtLink>
</li>
<li>
<NuxtLink to="/admin/events" :class="{ active: route.path.startsWith('/admin/events') }" @click="isMobileMenuOpen = false">
Events
</NuxtLink>
</li>
<li>
<NuxtLink to="/admin/series-management" :class="{ active: route.path.includes('/admin/series') }" @click="isMobileMenuOpen = false">
Series
</NuxtLink>
</li>
</ul>
<div class="sidebar-section">Site</div>
<ul class="sidebar-nav">
<li><NuxtLink to="/member/dashboard" @click="isMobileMenuOpen = false">Your Dashboard</NuxtLink></li>
<li><NuxtLink to="/" @click="isMobileMenuOpen = false">Public Site</NuxtLink></li>
</ul>
</div>
<div class="sidebar-meta">
<span class="admin-tag">admin</span><br>
<a href="#" @click.prevent="logout">Sign out</a>
</div>
</aside>
</template>
</USlideover>
</div> </div>
</template> </template>
<script setup> <script setup>
const showUserMenu = ref(false); const route = useRoute()
const isMobileMenuOpen = ref(false)
// Close user menu when clicking outside const currentPageName = computed(() => {
const vClickOutside = { const path = route.path
beforeMount(el, binding) { if (path === '/admin') return 'admin'
el.clickOutsideEvent = (event) => { return path.slice(1).replace(/\//g, ' / ')
if (!(el === event.target || el.contains(event.target))) { })
binding.value();
}
};
document.addEventListener("click", el.clickOutsideEvent);
},
unmounted(el) {
document.removeEventListener("click", el.clickOutsideEvent);
},
};
const logout = async () => { const logout = async () => {
try { try {
await $fetch("/api/auth/logout", { method: "POST" }); await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo("/"); await navigateTo('/')
} catch (error) { } catch (error) {
console.error("Logout failed:", error); console.error('Logout failed:', error)
} }
}; }
</script> </script>
<style scoped>
.site {
min-height: 100vh;
}
.main {
margin-left: 220px;
}
/* ---- SIDEBAR ---- */
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 220px;
display: flex;
flex-direction: column;
background: var(--bg);
border-right: 1px dashed var(--border);
z-index: 10;
}
.sidebar-mobile {
position: static;
width: 100%;
min-height: 100%;
border-right: none;
}
.sidebar-brand {
display: block;
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 600;
color: var(--candle);
padding: 24px 24px 16px;
border-bottom: 1px dashed var(--border);
text-decoration: none;
}
.sidebar-brand:hover { text-decoration: none; }
.sidebar-body {
flex: 1;
overflow-y: auto;
}
.sidebar-section {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
margin: 20px 0 8px;
padding: 0 24px;
}
.sidebar-nav {
list-style: none;
margin-bottom: 0;
padding: 0 14px;
}
.sidebar-nav li {
margin-bottom: 2px;
}
.sidebar-nav a {
display: block;
padding: 6px 10px;
color: var(--text-dim);
font-size: 13px;
transition: all 0.15s;
text-decoration: none;
}
.sidebar-nav a:hover {
color: var(--text);
background: var(--surface);
text-decoration: none;
}
.sidebar-nav a.active {
color: var(--text-bright);
background: var(--surface);
}
.sidebar-meta {
margin-top: auto;
font-size: 11px;
color: var(--text-faint);
line-height: 1.7;
padding: 16px 24px 24px;
border-top: 1px dashed var(--border);
}
.sidebar-meta a {
color: var(--candle-dim);
}
.admin-tag {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--candle);
}
/* ---- MOBILE ---- */
.sidebar-desktop {
display: block;
}
.mobile-header {
display: none;
}
.brand {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 600;
color: var(--candle);
text-decoration: none;
}
@media (max-width: 768px) {
.sidebar-desktop {
display: none;
}
.mobile-header {
display: flex;
padding: 12px 20px;
border-bottom: 1px dashed var(--border);
align-items: center;
justify-content: space-between;
background: var(--bg);
position: sticky;
top: 0;
z-index: 50;
}
.main {
margin-left: 0;
}
}
</style>

View file

@ -1,118 +1,43 @@
<template> <template>
<div class="min-h-screen bg-guild-900"> <div class="landing">
<!-- Horizontal Navigation --> <!-- Horizontal Navigation -->
<nav class="w-full px-6 md:px-8 py-4"> <nav class="landing-nav">
<div class="max-w-6xl mx-auto flex items-center justify-between"> <div class="landing-nav-inner">
<!-- Logo/Wordmark --> <NuxtLink to="/" class="landing-brand">Ghost Guild</NuxtLink>
<NuxtLink to="/" class="text-display-sm font-bold text-candlelight-400 warm-text tracking-wide"> <div class="landing-links">
Ghost Guild <NuxtLink to="/about">About</NuxtLink>
</NuxtLink> <NuxtLink to="/events">Events</NuxtLink>
<!-- Desktop Navigation Links -->
<div class="hidden md:flex items-center gap-8">
<NuxtLink
to="/about"
class="text-guild-300 hover:text-guild-100 transition-colors text-sm"
>
About
</NuxtLink>
<NuxtLink
to="/events"
class="text-guild-300 hover:text-guild-100 transition-colors text-sm"
>
Events
</NuxtLink>
<template v-if="isAuthenticated"> <template v-if="isAuthenticated">
<NuxtLink <NuxtLink to="/member/dashboard" class="landing-cta">Dashboard</NuxtLink>
to="/member/dashboard"
class="text-primary-400 hover:text-primary-300 transition-colors text-sm font-medium"
>
Dashboard
</NuxtLink>
</template> </template>
<template v-else> <template v-else>
<button <button class="landing-cta" @click="openLoginModal">Sign In</button>
@click="openLoginModal"
class="text-primary-400 hover:text-primary-300 transition-colors text-sm font-medium"
>
Sign In
</button>
</template> </template>
</div> </div>
<button class="btn landing-menu-btn" @click="isMobileMenuOpen = true">Menu</button>
<!-- Mobile Menu Button -->
<UButton
icon="i-lucide-menu"
color="neutral"
variant="ghost"
size="md"
class="md:hidden"
@click="isMobileMenuOpen = true"
aria-label="Open menu"
/>
</div> </div>
</nav> </nav>
<!-- Main Content -->
<main> <main>
<slot /> <slot />
</main> </main>
<!-- Footer -->
<footer class="border-t border-guild-800 py-8 px-6 md:px-8">
<div class="max-w-6xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-guild-500 text-sm">
&copy; {{ currentYear }} Ghost Guild
</p>
<a
href="mailto:hello@ghostguild.org"
class="text-guild-500 hover:text-guild-300 transition-colors text-sm"
>
hello@ghostguild.org
</a>
</div>
</footer>
<!-- Mobile Navigation Drawer --> <!-- Mobile Navigation Drawer -->
<USlideover v-model:open="isMobileMenuOpen" side="right"> <USlideover v-model:open="isMobileMenuOpen" side="right">
<template #body> <template #body>
<div class="p-6 space-y-6"> <div class="landing-mobile-nav">
<NuxtLink <NuxtLink to="/about" @click="isMobileMenuOpen = false">About</NuxtLink>
to="/about" <NuxtLink to="/events" @click="isMobileMenuOpen = false">Events</NuxtLink>
class="block text-guild-200 hover:text-guild-100 transition-colors text-lg"
@click="isMobileMenuOpen = false"
>
About
</NuxtLink>
<NuxtLink
to="/events"
class="block text-guild-200 hover:text-guild-100 transition-colors text-lg"
@click="isMobileMenuOpen = false"
>
Events
</NuxtLink>
<template v-if="isAuthenticated"> <template v-if="isAuthenticated">
<NuxtLink <NuxtLink to="/member/dashboard" @click="isMobileMenuOpen = false">Dashboard</NuxtLink>
to="/member/dashboard"
class="block text-primary-400 hover:text-primary-300 transition-colors text-lg font-medium"
@click="isMobileMenuOpen = false"
>
Dashboard
</NuxtLink>
</template> </template>
<template v-else> <template v-else>
<button <button @click="handleMobileSignIn">Sign In</button>
@click="handleMobileSignIn"
class="block text-primary-400 hover:text-primary-300 transition-colors text-lg font-medium"
>
Sign In
</button>
</template> </template>
</div> </div>
</template> </template>
</USlideover> </USlideover>
<!-- Login Modal -->
<LoginModal /> <LoginModal />
</div> </div>
</template> </template>
@ -122,10 +47,101 @@ const { isAuthenticated } = useAuth()
const { openLoginModal } = useLoginModal() const { openLoginModal } = useLoginModal()
const isMobileMenuOpen = ref(false) const isMobileMenuOpen = ref(false)
const currentYear = new Date().getFullYear()
const handleMobileSignIn = () => { const handleMobileSignIn = () => {
isMobileMenuOpen.value = false isMobileMenuOpen.value = false
openLoginModal() openLoginModal()
} }
</script> </script>
<style scoped>
.landing {
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
.landing-nav {
border-bottom: 1px dashed var(--border);
padding: 0 32px;
}
.landing-nav-inner {
max-width: 960px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
}
.landing-brand {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 600;
color: var(--candle);
text-decoration: none;
}
.landing-brand:hover { text-decoration: none; }
.landing-links {
display: flex;
align-items: center;
gap: 24px;
}
.landing-links a,
.landing-links button {
font-family: 'Commit Mono', monospace;
font-size: 13px;
color: var(--text-dim);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s;
}
.landing-links a:hover,
.landing-links button:hover {
color: var(--text-bright);
text-decoration: none;
}
.landing-cta {
color: var(--candle) !important;
}
.landing-menu-btn {
display: none;
}
.landing-mobile-nav {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.landing-mobile-nav a,
.landing-mobile-nav button {
font-family: 'Commit Mono', monospace;
font-size: 16px;
color: var(--text);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-align: left;
}
.landing-mobile-nav a:hover,
.landing-mobile-nav button:hover {
color: var(--candle);
}
@media (max-width: 768px) {
.landing-links { display: none; }
.landing-menu-btn { display: block; }
.landing-nav { padding: 0 20px; }
}
</style>

View file

@ -1,359 +1,13 @@
<template> <template>
<div> <div />
<div class="bg-guild-900 border-b border-guild-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-display font-bold text-guild-100">Admin Dashboard</h1>
<p class="text-guild-400">
Manage Ghost Guild members, events, and community operations
</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Quick Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-guild-900 rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-guild-400">Total Members</p>
<p class="text-2xl font-bold text-candlelight-400 text-ui-mono">
{{ stats.totalMembers || 0 }}
</p>
</div>
<div
class="w-12 h-12 bg-candlelight-900/20 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"
></path>
</svg>
</div>
</div>
</div>
<div class="bg-guild-900 rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-guild-400">Active Events</p>
<p class="text-2xl font-bold text-candlelight-400 text-ui-mono">
{{ stats.activeEvents || 0 }}
</p>
</div>
<div
class="w-12 h-12 bg-candlelight-900/20 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
</div>
</div>
</div>
<div class="bg-guild-900 rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-guild-400">Monthly Revenue</p>
<p class="text-2xl font-bold text-candlelight-400 text-ui-mono">
${{ stats.monthlyRevenue || 0 }}
</p>
</div>
<div
class="w-12 h-12 bg-candlelight-900/20 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
></path>
</svg>
</div>
</div>
</div>
<div class="bg-guild-900 rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-guild-400">Pending Slack Invites</p>
<p class="text-2xl font-bold text-candlelight-400 text-ui-mono">
{{ stats.pendingSlackInvites || 0 }}
</p>
</div>
<div
class="w-12 h-12 bg-candlelight-900/20 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
></path>
</svg>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-guild-900 rounded-lg shadow p-6">
<div class="text-center">
<div
class="w-16 h-16 bg-candlelight-900/20 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
></path>
</svg>
</div>
<h3 class="text-display-sm font-semibold text-guild-100 mb-2">
Add New Member
</h3>
<p class="text-guild-400 text-sm mb-4">
Add a new member to the Ghost Guild community
</p>
<button
@click="navigateTo('/admin/members-working')"
class="w-full bg-candlelight-600 text-white py-2 px-4 rounded-lg hover:bg-candlelight-700 focus:ring-2 focus:ring-candlelight-500 focus:ring-offset-2"
>
Manage Members
</button>
</div>
</div>
<div class="bg-guild-900 rounded-lg shadow p-6">
<div class="text-center">
<div
class="w-16 h-16 bg-candlelight-900/20 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
></path>
</svg>
</div>
<h3 class="text-display-sm font-semibold text-guild-100 mb-2">
Create Event
</h3>
<p class="text-guild-400 text-sm mb-4">
Schedule a new community event or workshop
</p>
<button
@click="navigateTo('/admin/events-working')"
class="w-full bg-candlelight-600 text-white py-2 px-4 rounded-lg hover:bg-candlelight-700 focus:ring-2 focus:ring-candlelight-500 focus:ring-offset-2"
>
Manage Events
</button>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-guild-900 rounded-lg shadow">
<div class="px-6 py-4 border-b border-guild-700">
<div class="flex justify-between items-center">
<h3 class="text-display-sm font-semibold text-guild-100">
Recent Members
</h3>
<button
@click="navigateTo('/admin/members-working')"
class="text-sm text-candlelight-400 hover:text-candlelight-300"
>
View All
</button>
</div>
</div>
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-candlelight-500 mx-auto"
></div>
</div>
<div v-else-if="recentMembers.length" class="space-y-3">
<div
v-for="member in recentMembers"
:key="member._id"
class="flex items-center justify-between p-3 rounded-lg border border-guild-700"
>
<div>
<p class="font-medium text-guild-100">
{{ member.name }}
</p>
<p class="text-sm text-guild-400">
{{ member.email }}
</p>
</div>
<div class="text-right">
<span
:class="getCircleBadgeClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ member.circle }}
</span>
<p class="text-xs text-guild-500 text-ui-mono">
{{ formatDate(member.createdAt) }}
</p>
</div>
</div>
</div>
<div v-else class="text-center py-6 text-guild-500">
No recent members
</div>
</div>
</div>
<div class="bg-guild-900 rounded-lg shadow">
<div class="px-6 py-4 border-b border-guild-700">
<div class="flex justify-between items-center">
<h3 class="text-display-sm font-semibold text-guild-100">
Upcoming Events
</h3>
<button
@click="navigateTo('/admin/events-working')"
class="text-sm text-candlelight-400 hover:text-candlelight-300"
>
View All
</button>
</div>
</div>
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-candlelight-500 mx-auto"
></div>
</div>
<div v-else-if="upcomingEvents.length" class="space-y-3">
<div
v-for="event in upcomingEvents"
:key="event._id"
class="flex items-center justify-between p-3 rounded-lg border border-guild-700"
>
<div>
<p class="font-medium text-guild-100">
{{ event.title }}
</p>
<p class="text-sm text-guild-400">
{{ formatDateTime(event.startDate) }}
</p>
</div>
<div class="text-right">
<span
:class="getEventTypeBadgeClasses(event.eventType)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ event.eventType }}
</span>
<p class="text-xs text-guild-500">
{{ event.location || "Online" }}
</p>
</div>
</div>
</div>
<div v-else class="text-center py-6 text-guild-500">
No upcoming events
</div>
</div>
</div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
definePageMeta({ definePageMeta({
layout: "admin", layout: 'admin',
}); middleware: 'admin',
})
const { data: dashboardData, pending } = await useFetch("/api/admin/dashboard"); // Redirect to the main admin page
await navigateTo('/admin', { replace: true })
const stats = computed(() => dashboardData.value?.stats || {});
const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
const upcomingEvents = computed(
() => dashboardData.value?.upcomingEvents || [],
);
const getCircleBadgeClasses = (circle) => {
const classes = {
community: "bg-candlelight-900/20 text-candlelight-400",
founder: "bg-earth-900/20 text-earth-400",
practitioner: "bg-candlelight-900/20 text-candlelight-400",
};
return classes[circle] || "bg-guild-800 text-guild-300";
};
const getEventTypeBadgeClasses = (type) => {
const classes = {
community: "bg-candlelight-900/20 text-candlelight-400",
workshop: "bg-candlelight-900/20 text-candlelight-400",
social: "bg-earth-900/20 text-earth-400",
showcase: "bg-ember-900/20 text-ember-400",
};
return classes[type] || "bg-guild-800 text-guild-300";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString();
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
</script> </script>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,312 +1,98 @@
<template> <template>
<div> <div class="admin-dash">
<div class="bg-guild-900 border-b border-guild-700"> <!-- Page Header -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="page-header">
<div class="py-6"> <h1>Admin Dashboard</h1>
<h1 class="text-display font-bold text-guild-100">Admin Dashboard</h1> <p>Members, events, and community operations</p>
<p class="text-guild-400"> </div>
Manage Ghost Guild members, events, and community operations
</p> <!-- Stats + Quick Actions row -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Overview</div>
<div class="stat-row">
<span class="stat-key">Total Members</span>
<span class="stat-val">{{ stats.totalMembers || 0 }}</span>
</div> </div>
<div class="stat-row">
<span class="stat-key">Active Events</span>
<span class="stat-val">{{ stats.activeEvents || 0 }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Monthly Revenue</span>
<span class="stat-val">${{ stats.monthlyRevenue || 0 }}</span>
</div>
<div class="stat-row">
<span class="stat-key">Pending Slack Invites</span>
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
</div>
</div>
<div class="content-block">
<div class="section-label">Quick Actions</div>
<NuxtLink to="/admin/members" class="action-link">
Manage Members<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events" class="action-link">
Manage Events<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events/create" class="action-link">
Create Event<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/series/create" class="action-link">
Create Series<span class="arrow">&rarr;</span>
</NuxtLink>
</div> </div>
</div> </div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <!-- Recent Activity row -->
<!-- Quick Stats --> <div class="content-row">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> <div class="content-block">
<div class="bg-guild-900 rounded-lg shadow p-6"> <div class="section-label">Recent Members</div>
<div class="flex items-center justify-between">
<div> <div v-if="pending" class="loading-inline">
<p class="text-sm text-guild-400">Total Members</p> <div class="spinner spinner-sm" />
<p class="text-2xl font-bold text-candlelight-400 text-ui-mono">
{{ stats.totalMembers || 0 }}
</p>
</div>
<div
class="w-12 h-12 bg-candlelight-900/20 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"
></path>
</svg>
</div>
</div>
</div> </div>
<div class="bg-guild-900 rounded-lg shadow p-6"> <div v-else-if="recentMembers.length" class="item-list">
<div class="flex items-center justify-between"> <div v-for="member in recentMembers" :key="member._id" class="item-row">
<div> <div>
<p class="text-sm text-guild-400">Active Events</p> <span class="item-name">{{ member.name }}</span>
<p class="text-2xl font-bold text-candlelight-400 text-ui-mono"> <span class="item-sub">{{ member.email }}</span>
{{ stats.activeEvents || 0 }}
</p>
</div> </div>
<div <div class="item-meta">
class="w-12 h-12 bg-candlelight-900/20 rounded-xl flex items-center justify-center" <span class="badge" :class="member.circle">{{ member.circle }}</span>
> <span class="item-date">{{ formatDate(member.createdAt) }}</span>
<svg
class="w-6 h-6 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="empty-state">No recent members</div>
<div class="bg-guild-900 rounded-lg shadow p-6"> <NuxtLink to="/admin/members" class="section-link">View all members &rarr;</NuxtLink>
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-guild-400">Monthly Revenue</p>
<p class="text-2xl font-bold text-candlelight-400 text-ui-mono">
${{ stats.monthlyRevenue || 0 }}
</p>
</div>
<div
class="w-12 h-12 bg-candlelight-900/20 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
></path>
</svg>
</div>
</div>
</div>
<div class="bg-guild-900 rounded-lg shadow p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-guild-400">Pending Slack Invites</p>
<p class="text-2xl font-bold text-candlelight-400 text-ui-mono">
{{ stats.pendingSlackInvites || 0 }}
</p>
</div>
<div
class="w-12 h-12 bg-candlelight-900/20 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
></path>
</svg>
</div>
</div>
</div>
</div> </div>
<!-- Quick Actions --> <div class="content-block">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> <div class="section-label">Upcoming Events</div>
<div class="bg-guild-900 rounded-lg shadow p-6">
<div class="text-center"> <div v-if="pending" class="loading-inline">
<div <div class="spinner spinner-sm" />
class="w-16 h-16 bg-candlelight-900/20 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
></path>
</svg>
</div>
<h3 class="text-display-sm font-semibold mb-2 text-guild-100">
Add New Member
</h3>
<p class="text-guild-400 text-sm mb-4">
Add a new member to the Ghost Guild community
</p>
<button
@click="navigateTo('/admin/members')"
class="w-full bg-candlelight-600 text-white py-2 px-4 rounded-lg hover:bg-candlelight-700 focus:ring-2 focus:ring-candlelight-500 focus:ring-offset-2"
>
Manage Members
</button>
</div>
</div> </div>
<div class="bg-guild-900 rounded-lg shadow p-6"> <div v-else-if="upcomingEvents.length" class="item-list">
<div class="text-center"> <div v-for="event in upcomingEvents" :key="event._id" class="item-row">
<div <div>
class="w-16 h-16 bg-candlelight-900/20 rounded-2xl flex items-center justify-center mx-auto mb-4" <span class="item-name">{{ event.title }}</span>
> <span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
<svg
class="w-8 h-8 text-candlelight-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
></path>
</svg>
</div> </div>
<h3 class="text-display-sm font-semibold mb-2 text-guild-100"> <div class="item-meta">
Create Event <span class="badge" :class="event.eventType">{{ event.eventType }}</span>
</h3> <span class="item-date">{{ event.location || 'Online' }}</span>
<p class="text-guild-400 text-sm mb-4">
Schedule a new community event or workshop
</p>
<button
@click="navigateTo('/admin/events')"
class="w-full bg-candlelight-600 text-white py-2 px-4 rounded-lg hover:bg-candlelight-700 focus:ring-2 focus:ring-candlelight-500 focus:ring-offset-2"
>
Manage Events
</button>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-guild-900 rounded-lg shadow">
<div class="px-6 py-4 border-b border-guild-700">
<div class="flex justify-between items-center">
<h3 class="text-display-sm font-semibold text-guild-100">
Recent Members
</h3>
<button
@click="navigateTo('/admin/members')"
class="text-sm text-candlelight-400 hover:text-candlelight-300"
>
View All
</button>
</div>
</div>
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-candlelight-500 mx-auto"
></div>
</div>
<div v-else-if="recentMembers.length" class="space-y-3">
<div
v-for="member in recentMembers"
:key="member._id"
class="flex items-center justify-between p-3 rounded-lg border border-guild-700"
>
<div>
<p class="font-medium text-guild-100">
{{ member.name }}
</p>
<p class="text-sm text-guild-400">
{{ member.email }}
</p>
</div>
<div class="text-right">
<span
:class="getCircleBadgeClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ member.circle }}
</span>
<p class="text-xs text-guild-500 text-ui-mono">
{{ formatDate(member.createdAt) }}
</p>
</div>
</div>
</div>
<div v-else class="text-center py-6 text-guild-500">
No recent members
</div> </div>
</div> </div>
</div> </div>
<div v-else class="empty-state">No upcoming events</div>
<div class="bg-guild-900 rounded-lg shadow"> <NuxtLink to="/admin/events" class="section-link">View all events &rarr;</NuxtLink>
<div class="px-6 py-4 border-b border-guild-700">
<div class="flex justify-between items-center">
<h3 class="text-display-sm font-semibold text-guild-100">
Upcoming Events
</h3>
<button
@click="navigateTo('/admin/events')"
class="text-sm text-candlelight-400 hover:text-candlelight-300"
>
View All
</button>
</div>
</div>
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-candlelight-500 mx-auto"
></div>
</div>
<div v-else-if="upcomingEvents.length" class="space-y-3">
<div
v-for="event in upcomingEvents"
:key="event._id"
class="flex items-center justify-between p-3 rounded-lg border border-guild-700"
>
<div>
<p class="font-medium text-guild-100">
{{ event.title }}
</p>
<p class="text-sm text-guild-400">
{{ formatDateTime(event.startDate) }}
</p>
</div>
<div class="text-right">
<span
:class="getEventTypeBadgeClasses(event.eventType)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ event.eventType }}
</span>
<p class="text-xs text-guild-500">
{{ event.location || "Online" }}
</p>
</div>
</div>
</div>
<div v-else class="text-center py-6 text-guild-500">
No upcoming events
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -314,46 +100,233 @@
<script setup> <script setup>
definePageMeta({ definePageMeta({
layout: "admin", layout: 'admin',
}); middleware: 'admin',
})
const { data: dashboardData, pending } = await useFetch("/api/admin/dashboard"); const { data: dashboardData, pending } = await useFetch('/api/admin/dashboard')
const stats = computed(() => dashboardData.value?.stats || {}); const stats = computed(() => dashboardData.value?.stats || {})
const recentMembers = computed(() => dashboardData.value?.recentMembers || []); const recentMembers = computed(() => dashboardData.value?.recentMembers || [])
const upcomingEvents = computed( const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || [])
() => dashboardData.value?.upcomingEvents || [],
);
const getCircleBadgeClasses = (circle) => {
const classes = {
community: "bg-candlelight-900/20 text-candlelight-400",
founder: "bg-earth-900/20 text-earth-400",
practitioner: "bg-candlelight-900/20 text-candlelight-400",
};
return classes[circle] || "bg-guild-800 text-guild-300";
};
const getEventTypeBadgeClasses = (type) => {
const classes = {
community: "bg-candlelight-900/20 text-candlelight-400",
workshop: "bg-candlelight-900/20 text-candlelight-400",
social: "bg-earth-900/20 text-earth-400",
showcase: "bg-ember-900/20 text-ember-400",
};
return classes[type] || "bg-guild-800 text-guild-300";
};
const formatDate = (dateString) => { const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString(); return new Date(dateString).toLocaleDateString()
}; }
const formatDateTime = (dateString) => { const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString(undefined, { return new Date(dateString).toLocaleDateString(undefined, {
month: "short", month: 'short',
day: "numeric", day: 'numeric',
hour: "numeric", hour: 'numeric',
minute: "2-digit", minute: '2-digit',
}); })
}; }
</script> </script>
<style scoped>
.admin-dash {
max-width: 960px;
margin: 0 auto;
}
/* ---- PAGE HEADER ---- */
.page-header {
padding: 28px 28px 20px;
border-bottom: 1px dashed var(--border);
}
.page-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 4px;
}
.page-header p {
font-size: 12px;
color: var(--text-dim);
}
/* ---- CONTENT GRID ---- */
.content-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: 1px dashed var(--border);
}
.content-block {
padding: 24px 28px;
border-right: 1px dashed var(--border);
min-width: 0;
}
.content-block:last-child {
border-right: none;
}
/* ---- STATS ---- */
.stat-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.stat-row:last-of-type {
border-bottom: none;
}
.stat-key {
color: var(--text-faint);
}
.stat-val {
color: var(--text-bright);
font-weight: 600;
}
/* ---- QUICK ACTIONS ---- */
.action-link {
border: 1px dashed var(--border);
padding: 14px 20px;
margin-bottom: 8px;
transition: border-color 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text);
font-size: 13px;
text-decoration: none;
}
.action-link:hover {
border-color: var(--candle-faint);
color: var(--candle);
text-decoration: none;
}
.action-link .arrow {
color: var(--text-faint);
margin-left: 8px;
transition: color 0.2s;
}
.action-link:hover .arrow {
color: var(--candle-faint);
}
/* ---- ITEM LIST (members / events) ---- */
.item-list {
margin-bottom: 4px;
}
.item-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
gap: 12px;
}
.item-row:last-child {
border-bottom: none;
}
.item-name {
display: block;
color: var(--text);
font-size: 13px;
}
.item-sub {
display: block;
color: var(--text-faint);
font-size: 11px;
margin-top: 2px;
}
.item-meta {
text-align: right;
flex-shrink: 0;
}
.item-date {
display: block;
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}
/* ---- SECTION LINK ---- */
.section-link {
display: inline-block;
margin-top: 16px;
font-size: 12px;
color: var(--candle);
text-decoration: none;
}
.section-link:hover {
text-decoration: underline;
}
/* ---- STATES ---- */
.loading-inline {
padding: 24px 0;
text-align: center;
}
.spinner {
width: 24px;
height: 24px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
.spinner-sm {
width: 16px;
height: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
padding: 24px 0;
color: var(--text-faint);
font-size: 12px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.content-row {
grid-template-columns: 1fr;
}
.content-block {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.content-block:last-child {
border-bottom: none;
}
.page-header {
padding: 24px 20px 16px;
}
.content-block {
padding: 20px;
}
}
</style>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,143 +1,123 @@
<template> <template>
<div> <div class="create-form">
<div class="bg-guild-900 border-b border-guild-700"> <div class="page-header">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <NuxtLink to="/admin/series-management" class="back-link">&larr; Series</NuxtLink>
<div class="py-6"> <h1>Create New Series</h1>
<div class="flex items-center gap-4 mb-2"> <p>Create a new event series to group related events together</p>
<NuxtLink to="/admin/series-management" class="text-guild-500 hover:text-guild-100">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</NuxtLink>
<h1 class="text-display font-bold text-guild-100">Create New Series</h1>
</div>
<p class="text-guild-400">Create a new event series to group related events together</p>
</div>
</div>
</div> </div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="form-body">
<!-- Error Summary --> <!-- Error Summary -->
<div v-if="formErrors.length > 0" class="mb-6 p-4 bg-ember-900/20 border border-ember-800 rounded-lg"> <div v-if="formErrors.length > 0" class="error-box">
<div class="flex"> <Icon name="heroicons:exclamation-circle" class="box-icon" />
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-ember-400 mr-3 mt-0.5" /> <div>
<div> <strong>Please fix the following errors:</strong>
<h3 class="text-sm font-medium text-ember-400 mb-2">Please fix the following errors:</h3> <ul>
<ul class="text-sm text-ember-400 space-y-1"> <li v-for="error in formErrors" :key="error">{{ error }}</li>
<li v-for="error in formErrors" :key="error"> {{ error }}</li> </ul>
</ul>
</div>
</div> </div>
</div> </div>
<!-- Success Message --> <!-- Success Message -->
<div v-if="showSuccessMessage" class="mb-6 p-4 bg-candlelight-900/20 border border-candlelight-800 rounded-lg"> <div v-if="showSuccessMessage" class="success-box">
<div class="flex"> <Icon name="heroicons:check-circle" class="box-icon" />
<Icon name="heroicons:check-circle" class="w-5 h-5 text-candlelight-400 mr-3 mt-0.5" /> <div>
<div> <strong>Series created successfully!</strong>
<h3 class="text-sm font-medium text-candlelight-400">Series created successfully!</h3>
</div>
</div> </div>
</div> </div>
<form @submit.prevent="createSeries"> <form @submit.prevent="createSeries">
<!-- Series Information --> <!-- Series Information -->
<div class="mb-8"> <div class="form-section">
<h2 class="text-lg font-semibold text-guild-100 mb-4">Series Information</h2> <h2 class="section-heading">Series Information</h2>
<div class="grid grid-cols-1 gap-6"> <div class="field">
<div> <label>
<label class="block text-sm font-medium text-guild-100 mb-2"> Series Title <span class="required">*</span>
Series Title <span class="text-ember-400">*</span> </label>
</label> <input
<input v-model="seriesForm.title"
v-model="seriesForm.title" type="text"
type="text" placeholder="e.g., Cooperative Game Development Fundamentals"
placeholder="e.g., Cooperative Game Development Fundamentals" required
required :class="{ 'has-error': fieldErrors.title }"
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-earth-500 focus:border-transparent" @input="generateSlugFromTitle"
:class="{ 'border-ember-700 focus:ring-ember-500': fieldErrors.title }" />
@input="generateSlugFromTitle" <p v-if="fieldErrors.title" class="field-error">{{ fieldErrors.title }}</p>
/> </div>
<p v-if="fieldErrors.title" class="mt-1 text-sm text-ember-400">{{ fieldErrors.title }}</p>
<div v-if="generatedSlug" class="field">
<label>Generated Series ID</label>
<div class="slug-display">
{{ generatedSlug }}
</div>
<p class="help-text">
This unique identifier will be automatically generated from your title
</p>
</div>
<div class="field">
<label>
Series Description <span class="required">*</span>
</label>
<textarea
v-model="seriesForm.description"
placeholder="Describe what the series covers and its goals"
required
rows="4"
:class="{ 'has-error': fieldErrors.description }"
></textarea>
<p v-if="fieldErrors.description" class="field-error">{{ fieldErrors.description }}</p>
</div>
<div class="form-grid">
<div class="field">
<label>Series Type</label>
<select v-model="seriesForm.type">
<option value="workshop_series">Workshop Series</option>
<option value="recurring_meetup">Recurring Meetup</option>
<option value="multi_day">Multi-Day Event</option>
<option value="course">Course</option>
<option value="tournament">Tournament</option>
</select>
</div> </div>
<div v-if="generatedSlug"> <div class="field">
<label class="block text-sm font-medium text-guild-100 mb-2">Generated Series ID</label> <label>Total Events Planned</label>
<div class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 font-mono text-sm"> <input
{{ generatedSlug }} v-model.number="seriesForm.totalEvents"
</div> type="number"
<p class="mt-1 text-sm text-guild-500"> min="1"
This unique identifier will be automatically generated from your title placeholder="e.g., 4"
</p> />
</div> <p class="help-text">How many events will be in this series? (optional)</p>
<div>
<label class="block text-sm font-medium text-guild-100 mb-2">
Series Description <span class="text-ember-400">*</span>
</label>
<textarea
v-model="seriesForm.description"
placeholder="Describe what the series covers and its goals"
required
rows="4"
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-earth-500 focus:border-transparent"
:class="{ 'border-ember-700 focus:ring-ember-500': fieldErrors.description }"
></textarea>
<p v-if="fieldErrors.description" class="mt-1 text-sm text-ember-400">{{ fieldErrors.description }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-guild-100 mb-2">Series Type</label>
<select
v-model="seriesForm.type"
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 focus:ring-2 focus:ring-earth-500 focus:border-transparent"
>
<option value="workshop_series">Workshop Series</option>
<option value="recurring_meetup">Recurring Meetup</option>
<option value="multi_day">Multi-Day Event</option>
<option value="course">Course</option>
<option value="tournament">Tournament</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-guild-100 mb-2">Total Events Planned</label>
<input
v-model.number="seriesForm.totalEvents"
type="number"
min="1"
placeholder="e.g., 4"
class="w-full bg-guild-800 border border-guild-700 rounded-lg px-3 py-2 text-guild-100 placeholder-guild-500 focus:ring-2 focus:ring-earth-500 focus:border-transparent"
/>
<p class="text-sm text-guild-500 mt-1">How many events will be in this series? (optional)</p>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Form Actions --> <!-- Form Actions -->
<div class="flex justify-between items-center pt-6 border-t border-guild-700"> <div class="form-actions">
<NuxtLink <NuxtLink
to="/admin/series-management" to="/admin/series-management"
class="px-4 py-2 text-guild-400 hover:text-guild-100 font-medium" class="btn"
> >
Cancel Cancel
</NuxtLink> </NuxtLink>
<div class="flex gap-3"> <div class="form-actions-right">
<button <button
type="button" type="button"
@click="createAndAddEvent" @click="createAndAddEvent"
:disabled="creating" :disabled="creating"
class="px-4 py-2 bg-candlelight-600 text-white rounded-lg hover:bg-candlelight-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium" class="btn"
> >
{{ creating ? 'Creating...' : 'Create & Add Event' }} {{ creating ? 'Creating...' : 'Create & Add Event' }}
</button> </button>
<button <button
type="submit" type="submit"
:disabled="creating" :disabled="creating"
class="px-6 py-2 bg-earth-600 text-white rounded-lg hover:bg-earth-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium" class="btn btn-primary"
> >
{{ creating ? 'Creating...' : 'Create Series' }} {{ creating ? 'Creating...' : 'Create Series' }}
</button> </button>
@ -150,7 +130,8 @@
<script setup> <script setup>
definePageMeta({ definePageMeta({
layout: 'admin' layout: 'admin',
middleware: 'admin',
}) })
const router = useRouter() const router = useRouter()
@ -190,22 +171,22 @@ const generateSlugFromTitle = () => {
const validateForm = () => { const validateForm = () => {
formErrors.value = [] formErrors.value = []
fieldErrors.value = {} fieldErrors.value = {}
if (!seriesForm.title.trim()) { if (!seriesForm.title.trim()) {
formErrors.value.push('Series title is required') formErrors.value.push('Series title is required')
fieldErrors.value.title = 'Please enter a series title' fieldErrors.value.title = 'Please enter a series title'
} }
if (!seriesForm.description.trim()) { if (!seriesForm.description.trim()) {
formErrors.value.push('Series description is required') formErrors.value.push('Series description is required')
fieldErrors.value.description = 'Please provide a description for the series' fieldErrors.value.description = 'Please provide a description for the series'
} }
if (!generatedSlug.value) { if (!generatedSlug.value) {
formErrors.value.push('Series title must generate a valid ID') formErrors.value.push('Series title must generate a valid ID')
fieldErrors.value.title = 'Please enter a title that can generate a valid series ID' fieldErrors.value.title = 'Please enter a title that can generate a valid series ID'
} }
return formErrors.value.length === 0 return formErrors.value.length === 0
} }
@ -214,7 +195,7 @@ const createSeries = async (redirectAfter = true) => {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
return false return false
} }
creating.value = true creating.value = true
try { try {
const response = await $fetch('/api/admin/series', { const response = await $fetch('/api/admin/series', {
@ -224,16 +205,16 @@ const createSeries = async (redirectAfter = true) => {
id: generatedSlug.value id: generatedSlug.value
} }
}) })
showSuccessMessage.value = true showSuccessMessage.value = true
setTimeout(() => { showSuccessMessage.value = false }, 5000) setTimeout(() => { showSuccessMessage.value = false }, 5000)
if (redirectAfter) { if (redirectAfter) {
setTimeout(() => { setTimeout(() => {
router.push('/admin/series-management') router.push('/admin/series-management')
}, 1500) }, 1500)
} }
return response.data return response.data
} catch (error) { } catch (error) {
console.error('Failed to create series:', error) console.error('Failed to create series:', error)
@ -260,9 +241,127 @@ const createAndAddEvent = async () => {
totalEvents: seriesForm.totalEvents totalEvents: seriesForm.totalEvents
} }
} }
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData)) sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
router.push('/admin/events/create?series=true') router.push('/admin/events/create?series=true')
} }
} }
</script> </script>
<style scoped>
.create-form {
max-width: 800px;
margin: 0 auto;
}
.page-header {
padding: 28px 28px 20px;
border-bottom: 1px dashed var(--border);
}
.page-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
line-height: 1.2;
margin-bottom: 4px;
}
.page-header p { font-size: 12px; color: var(--text-dim); }
.back-link {
font-size: 12px;
color: var(--candle);
text-decoration: none;
margin-bottom: 8px;
display: inline-block;
}
.back-link:hover { text-decoration: underline; }
.form-body { padding: 24px 28px; }
.form-section { margin-bottom: 32px; }
.section-heading {
font-family: 'Brygada 1918', serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
padding-bottom: 10px;
border-bottom: 1px dashed var(--border);
margin-bottom: 16px;
}
.error-box {
display: flex;
gap: 10px;
padding: 16px 20px;
border: 1px dashed var(--ember);
margin-bottom: 20px;
font-size: 12px;
color: var(--ember);
}
.error-box ul { margin-top: 6px; padding: 0; list-style: none; }
.error-box li::before { content: '\2022\00a0'; }
.success-box {
display: flex;
gap: 10px;
padding: 16px 20px;
border: 1px dashed var(--candle);
margin-bottom: 20px;
font-size: 12px;
color: var(--candle);
}
.box-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 1px;
}
.required { color: var(--ember); }
.has-error { border-color: var(--ember) !important; }
.help-text { font-size: 11px; color: var(--text-faint); margin-top: 4px; }
.field-error { font-size: 11px; color: var(--ember); margin-top: 4px; }
.slug-display {
padding: 5px 8px;
font-family: 'Commit Mono', monospace;
font-size: 13px;
color: var(--text-bright);
background: var(--surface);
border: 1px dashed var(--border);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 20px;
border-top: 1px dashed var(--border);
margin-top: 32px;
}
.form-actions-right {
display: flex;
gap: 8px;
}
@media (max-width: 768px) {
.page-header { padding: 24px 20px 16px; }
.form-body { padding: 20px; }
.form-grid { grid-template-columns: 1fr; }
}
</style>

View file

@ -21,27 +21,29 @@
<div class="membership-card"> <div class="membership-card">
<table> <table>
<tr> <tbody>
<td>Status</td> <tr>
<td> <td>Status</td>
<span class="status-dot" :class="memberData.status || 'active'"></span> <td>
{{ memberData.status || 'Active' }} <span class="status-dot" :class="memberData.status || 'active'"></span>
</td> {{ memberData.status || 'Active' }}
</tr> </td>
<tr> </tr>
<td>Circle</td> <tr>
<td :style="{ color: `var(--c-${memberData.circle || 'community'})` }"> <td>Circle</td>
{{ memberData.circle || 'Community' }} <td :style="{ color: `var(--c-${memberData.circle || 'community'})` }">
</td> {{ memberData.circle || 'Community' }}
</tr> </td>
<tr> </tr>
<td>Contribution</td> <tr>
<td>${{ memberData.contributionAmount || 0 }} / month</td> <td>Contribution</td>
</tr> <td>${{ memberData.contributionAmount || 0 }} / month</td>
<tr> </tr>
<td>Member since</td> <tr>
<td>{{ formatMemberSince(memberData.createdAt) }}</td> <td>Member since</td>
</tr> <td>{{ formatMemberSince(memberData.createdAt) }}</td>
</tr>
</tbody>
</table> </table>
</div> </div>

View file

@ -1,5 +1,6 @@
<template> <template>
<div class="dashboard"> <div class="dashboard">
<ClientOnly>
<!-- Loading State --> <!-- Loading State -->
<div v-if="authPending" class="loading-state"> <div v-if="authPending" class="loading-state">
<div class="spinner" /> <div class="spinner" />
@ -149,6 +150,14 @@
</div> </div>
</div> </div>
</template> </template>
<template #fallback>
<div class="loading-state">
<div class="spinner" />
<p>Loading your dashboard...</p>
</div>
</template>
</ClientOnly>
</div> </div>
</template> </template>

View file

@ -10,6 +10,7 @@
<!-- Main Content --> <!-- Main Content -->
<div class="content-main"> <div class="content-main">
<ClientOnly>
<!-- Stats + New Update row --> <!-- Stats + New Update row -->
<div v-if="isAuthenticated && !pending" class="stats-row"> <div v-if="isAuthenticated && !pending" class="stats-row">
@ -108,6 +109,14 @@
<NuxtLink to="/updates/new" class="btn btn-primary">+ Post Your First Update</NuxtLink> <NuxtLink to="/updates/new" class="btn btn-primary">+ Post Your First Update</NuxtLink>
</div> </div>
<template #fallback>
<div class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading your updates...</p>
</div>
</template>
</ClientOnly>
</div> </div>
<!-- Events Mini Sidebar --> <!-- Events Mini Sidebar -->

View file

@ -3,6 +3,11 @@ export default defineNuxtConfig({
compatibilityDate: "2025-07-15", compatibilityDate: "2025-07-15",
devtools: { enabled: process.env.NODE_ENV !== "production" }, devtools: { enabled: process.env.NODE_ENV !== "production" },
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"], modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
colorMode: {
preference: "system",
fallback: "light",
classSuffix: "",
},
app: { app: {
head: { head: {
link: [ link: [

View file

@ -56,7 +56,7 @@ export default defineEventHandler(async (event) => {
.replace(/\n/g, '<br>') .replace(/\n/g, '<br>')
.replace(/\{loginLink\}/g, loginButton) .replace(/\{loginLink\}/g, loginButton)
const { error: sendError } = await resend.emails.send({ const { error: emailError } = await resend.emails.send({
from: 'Ghost Guild <welcome@babyghosts.org>', from: 'Ghost Guild <welcome@babyghosts.org>',
to: [member.email], to: [member.email],
subject: 'You\'re invited to Ghost Guild', subject: 'You\'re invited to Ghost Guild',
@ -64,8 +64,8 @@ export default defineEventHandler(async (event) => {
html: emailHtml html: emailHtml
}) })
if (sendError) { if (emailError) {
results.push({ memberId: member._id, email: member.email, success: false, error: sendError.message }) results.push({ memberId: member._id, email: member.email, success: false, error: emailError.message })
continue continue
} }

View file

@ -0,0 +1,42 @@
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
// Only allow in development
if (process.env.NODE_ENV === 'production') {
throw createError({ statusCode: 404, statusMessage: 'Not found' })
}
await connectDB()
// Find or create a test admin user
let member = await Member.findOne({ email: 'test-admin@ghostguild.dev' })
if (!member) {
member = await Member.create({
email: 'test-admin@ghostguild.dev',
name: 'Test Admin',
circle: 'founder',
contributionTier: '0',
role: 'admin',
status: 'active',
})
}
const config = useRuntimeConfig(event)
const token = jwt.sign(
{ memberId: member._id, email: member.email },
config.jwtSecret,
{ expiresIn: '7d' }
)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
})
await sendRedirect(event, '/admin', 302)
})