Accessibility fixes.
This commit is contained in:
parent
4aacb26c4b
commit
88c94aaaf4
12 changed files with 276 additions and 260 deletions
|
|
@ -21,13 +21,13 @@
|
||||||
--border: #b8a880;
|
--border: #b8a880;
|
||||||
--border-d: #a89470;
|
--border-d: #a89470;
|
||||||
--candle: #7a5a10;
|
--candle: #7a5a10;
|
||||||
--candle-dim: #9a7420;
|
--candle-dim: #7a5a10;
|
||||||
--candle-faint: #c4a448;
|
--candle-faint: #c4a448;
|
||||||
--ember: #8a4420;
|
--ember: #8a4420;
|
||||||
--text: #2a2015;
|
--text: #2a2015;
|
||||||
--text-bright: #1a1008;
|
--text-bright: #1a1008;
|
||||||
--text-dim: #5a5040;
|
--text-dim: #5a5040;
|
||||||
--text-faint: #8a7e6a;
|
--text-faint: #6a5e4a;
|
||||||
--parch: #2a2015;
|
--parch: #2a2015;
|
||||||
--parch-hover: #3a3025;
|
--parch-hover: #3a3025;
|
||||||
--parch-text: #ede4d0;
|
--parch-text: #ede4d0;
|
||||||
|
|
@ -110,6 +110,11 @@ a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- SECTION LABELS ---- */
|
/* ---- SECTION LABELS ---- */
|
||||||
.section-label {
|
.section-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@
|
||||||
<div class="dev-actions">
|
<div class="dev-actions">
|
||||||
<div class="dev-buttons">
|
<div class="dev-buttons">
|
||||||
<a href="/api/dev/test-login" class="dev-button">Admin</a>
|
<a href="/api/dev/test-login" class="dev-button">Admin</a>
|
||||||
<button class="dev-button dev-logout" @click="handleLogout">Log out</button>
|
<button class="dev-button dev-logout" @click="handleLogout">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="selectedEmail"
|
v-model="selectedEmail"
|
||||||
|
|
@ -22,22 +24,24 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const selectedEmail = ref(null)
|
const selectedEmail = ref(null);
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth();
|
||||||
|
|
||||||
const { data: members } = await useFetch('/api/dev/members', {
|
const { data: members } = await useFetch("/api/dev/members", {
|
||||||
default: () => []
|
default: () => [],
|
||||||
})
|
});
|
||||||
|
|
||||||
const loginAsEmail = (email) => {
|
const loginAsEmail = (email) => {
|
||||||
if (email) {
|
if (email) {
|
||||||
navigateTo(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { external: true })
|
navigateTo(`/api/dev/member-login?email=${encodeURIComponent(email)}`, {
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout();
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -86,8 +90,16 @@ const handleLogout = async () => {
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.dev-select {
|
.dev-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep([data-slot="base"]) {
|
||||||
|
background: var(--bg);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep([data-slot="placeholder"]) {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,38 @@
|
||||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
// Skip on server-side rendering
|
// Skip on server-side rendering
|
||||||
if (process.server) {
|
if (process.server) {
|
||||||
console.log('🛡️ Auth middleware - skipping on server')
|
console.log("🛡️ Auth middleware - skipping on server");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth()
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal()
|
const { openLoginModal } = useLoginModal();
|
||||||
|
|
||||||
console.log('🛡️ Auth middleware (CLIENT) - route:', to.path)
|
console.log("🛡️ Auth middleware (CLIENT) - route:", to.path);
|
||||||
console.log(' - memberData exists:', !!memberData.value)
|
console.log(" - memberData exists:", !!memberData.value);
|
||||||
console.log(' - Running on:', process.server ? 'SERVER' : 'CLIENT')
|
console.log(" - Running on:", process.server ? "SERVER" : "CLIENT");
|
||||||
|
|
||||||
// If no member data, try to check authentication
|
// If no member data, try to check authentication
|
||||||
if (!memberData.value) {
|
if (!memberData.value) {
|
||||||
console.log(' - No member data, checking authentication...')
|
console.log(" - No member data, checking authentication...");
|
||||||
const isAuthenticated = await checkMemberStatus()
|
const isAuthenticated = await checkMemberStatus();
|
||||||
console.log(' - Authentication result:', isAuthenticated)
|
console.log(" - Authentication result:", isAuthenticated);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
console.log(' - ❌ Authentication failed, showing login modal')
|
console.log(" - ❌ Authentication failed, showing login modal");
|
||||||
// Open login modal instead of redirecting
|
// Open login modal instead of redirecting
|
||||||
openLoginModal({
|
openLoginModal({
|
||||||
title: 'Sign in to continue',
|
title: "Sign in to continue",
|
||||||
description: 'You need to be signed in to access this page',
|
description: "You need to be signed in to access this page",
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
redirectTo: to.fullPath,
|
redirectTo: to.fullPath,
|
||||||
})
|
});
|
||||||
// Abort navigation - stay on current page with modal open
|
// Let navigation proceed — the page renders its own unauthenticated
|
||||||
return abortNavigation()
|
// fallback, and the modal opens on top. abortNavigation() on an initial
|
||||||
|
// page load resets client state, which closes the modal before it shows.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(' - ✅ Authentication successful for:', memberData.value?.email)
|
console.log(" - ✅ Authentication successful for:", memberData.value?.email);
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<NuxtLink to="/admin/events" class="back-link">← Events</NuxtLink>
|
<NuxtLink to="/admin/events" class="back-link">← Events</NuxtLink>
|
||||||
<h1>{{ editingEvent ? 'Edit Event' : 'Create Event' }}</h1>
|
<h1>{{ editingEvent ? "Edit Event" : "Create Event" }}</h1>
|
||||||
<p>Fill out the form below to create or update an event</p>
|
<p>Fill out the form below to create or update an event</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,9 +32,7 @@
|
||||||
<h2 class="section-heading">Basic Information</h2>
|
<h2 class="section-heading">Basic Information</h2>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label> Event Title <span class="required">*</span> </label>
|
||||||
Event Title <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="eventForm.title"
|
v-model="eventForm.title"
|
||||||
placeholder="Enter a clear, descriptive event title"
|
placeholder="Enter a clear, descriptive event title"
|
||||||
|
|
@ -51,15 +49,13 @@
|
||||||
<label>Feature Image</label>
|
<label>Feature Image</label>
|
||||||
<ImageUpload v-model="eventForm.featureImage" />
|
<ImageUpload v-model="eventForm.featureImage" />
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
Upload a high-quality image (1200x630px recommended) to
|
Upload a high-quality image (1200x630px recommended) to represent
|
||||||
represent your event
|
your event
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label> Event Description <span class="required">*</span> </label>
|
||||||
Event Description <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<UTextarea
|
<UTextarea
|
||||||
v-model="eventForm.description"
|
v-model="eventForm.description"
|
||||||
placeholder="Provide a clear description of what attendees can expect from this event"
|
placeholder="Provide a clear description of what attendees can expect from this event"
|
||||||
|
|
@ -97,11 +93,10 @@
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label> Event Type <span class="required">*</span> </label>
|
||||||
Event Type <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<USelect
|
<USelect
|
||||||
v-model="eventForm.eventType"
|
v-model="eventForm.eventType"
|
||||||
|
aria-label="Event Type"
|
||||||
:items="[
|
:items="[
|
||||||
{ label: 'Community Meetup', value: 'community' },
|
{ label: 'Community Meetup', value: 'community' },
|
||||||
{ label: 'Workshop', value: 'workshop' },
|
{ label: 'Workshop', value: 'workshop' },
|
||||||
|
|
@ -116,9 +111,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label> Location <span class="required">*</span> </label>
|
||||||
Location <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="eventForm.location"
|
v-model="eventForm.location"
|
||||||
placeholder="e.g., https://zoom.us/j/123... or #channel-name"
|
placeholder="e.g., https://zoom.us/j/123... or #channel-name"
|
||||||
|
|
@ -135,9 +128,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label> Start Date & Time <span class="required">*</span> </label>
|
||||||
Start Date & Time <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<NaturalDateInput
|
<NaturalDateInput
|
||||||
v-model="eventForm.startDate"
|
v-model="eventForm.startDate"
|
||||||
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
||||||
|
|
@ -149,9 +140,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label> End Date & Time <span class="required">*</span> </label>
|
||||||
End Date & Time <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<NaturalDateInput
|
<NaturalDateInput
|
||||||
v-model="eventForm.endDate"
|
v-model="eventForm.endDate"
|
||||||
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
||||||
|
|
@ -248,22 +237,14 @@
|
||||||
<h2 class="section-heading">Ticketing</h2>
|
<h2 class="section-heading">Ticketing</h2>
|
||||||
|
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input
|
<input v-model="eventForm.tickets.enabled" type="checkbox" />
|
||||||
v-model="eventForm.tickets.enabled"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Enable Ticketing</strong>
|
<strong>Enable Ticketing</strong>
|
||||||
<span class="help-text">
|
<span class="help-text"> Allow ticket sales for this event </span>
|
||||||
Allow ticket sales for this event
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div v-if="eventForm.tickets.enabled" class="nested-section">
|
||||||
v-if="eventForm.tickets.enabled"
|
|
||||||
class="nested-section"
|
|
||||||
>
|
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input
|
<input
|
||||||
v-model="eventForm.tickets.public.available"
|
v-model="eventForm.tickets.public.available"
|
||||||
|
|
@ -298,9 +279,7 @@
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<p class="help-text">
|
<p class="help-text">Set to 0 for free public events</p>
|
||||||
Set to 0 for free public events
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -339,7 +318,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="eventForm.tickets.public.earlyBirdPrice > 0" class="field">
|
<div
|
||||||
|
v-if="eventForm.tickets.public.earlyBirdPrice > 0"
|
||||||
|
class="field"
|
||||||
|
>
|
||||||
<label>Early Bird Deadline</label>
|
<label>Early Bird Deadline</label>
|
||||||
<NaturalDateInput
|
<NaturalDateInput
|
||||||
v-model="eventForm.tickets.public.earlyBirdDeadline"
|
v-model="eventForm.tickets.public.earlyBirdDeadline"
|
||||||
|
|
@ -353,8 +335,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="note-box">
|
<div class="note-box">
|
||||||
<strong>Note:</strong> Members always get free access to all
|
<strong>Note:</strong> Members always get free access to all events
|
||||||
events regardless of ticket settings.
|
regardless of ticket settings.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -363,10 +345,7 @@
|
||||||
<h2 class="section-heading">Series Management</h2>
|
<h2 class="section-heading">Series Management</h2>
|
||||||
|
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input
|
<input v-model="eventForm.series.isSeriesEvent" type="checkbox" />
|
||||||
v-model="eventForm.series.isSeriesEvent"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Part of Event Series</strong>
|
<strong>Part of Event Series</strong>
|
||||||
<span class="help-text">
|
<span class="help-text">
|
||||||
|
|
@ -375,17 +354,13 @@
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div v-if="eventForm.series.isSeriesEvent" class="nested-section">
|
||||||
v-if="eventForm.series.isSeriesEvent"
|
|
||||||
class="nested-section"
|
|
||||||
>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label> Select Series <span class="required">*</span> </label>
|
||||||
Select Series <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="series-select-row">
|
<div class="series-select-row">
|
||||||
<USelect
|
<USelect
|
||||||
v-model="selectedSeriesId"
|
v-model="selectedSeriesId"
|
||||||
|
aria-label="Select Series"
|
||||||
@update:model-value="onSeriesSelect"
|
@update:model-value="onSeriesSelect"
|
||||||
:items="
|
:items="
|
||||||
availableSeries.map((series) => ({
|
availableSeries.map((series) => ({
|
||||||
|
|
@ -397,10 +372,7 @@
|
||||||
value-key="value"
|
value-key="value"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<NuxtLink
|
<NuxtLink to="/admin/series/create" class="btn btn-primary">
|
||||||
to="/admin/series/create"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
New Series
|
New Series
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -409,13 +381,9 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="selectedSeriesId || eventForm.series.id">
|
||||||
v-if="selectedSeriesId || eventForm.series.id"
|
|
||||||
>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label> Series Title <span class="required">*</span> </label>
|
||||||
Series Title <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="eventForm.series.title"
|
v-model="eventForm.series.title"
|
||||||
placeholder="e.g., Cooperative Game Development Fundamentals"
|
placeholder="e.g., Cooperative Game Development Fundamentals"
|
||||||
|
|
@ -454,8 +422,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedSeriesId" class="note-box">
|
<div v-if="selectedSeriesId" class="note-box">
|
||||||
<strong>Note:</strong> This event will be added to the
|
<strong>Note:</strong> This event will be added to the existing
|
||||||
existing "{{ eventForm.series.title }}" series.
|
"{{ eventForm.series.title }}" series.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -507,10 +475,7 @@
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="check-group">
|
<div class="check-group">
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input
|
<input v-model="eventForm.isOnline" type="checkbox" />
|
||||||
v-model="eventForm.isOnline"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Online Event</strong>
|
<strong>Online Event</strong>
|
||||||
<span class="help-text">
|
<span class="help-text">
|
||||||
|
|
@ -535,10 +500,7 @@
|
||||||
|
|
||||||
<div class="check-group">
|
<div class="check-group">
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input
|
<input v-model="eventForm.isVisible" type="checkbox" />
|
||||||
v-model="eventForm.isVisible"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Visible on Public Calendar</strong>
|
<strong>Visible on Public Calendar</strong>
|
||||||
<span class="help-text">
|
<span class="help-text">
|
||||||
|
|
@ -548,15 +510,10 @@
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input
|
<input v-model="eventForm.isCancelled" type="checkbox" />
|
||||||
v-model="eventForm.isCancelled"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Event Cancelled</strong>
|
<strong>Event Cancelled</strong>
|
||||||
<span class="help-text">
|
<span class="help-text"> Mark this event as cancelled </span>
|
||||||
Mark this event as cancelled
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -582,9 +539,7 @@
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<NuxtLink to="/admin/events" class="btn">
|
<NuxtLink to="/admin/events" class="btn"> Cancel </NuxtLink>
|
||||||
Cancel
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<div class="form-actions-right">
|
<div class="form-actions-right">
|
||||||
<button
|
<button
|
||||||
|
|
@ -597,11 +552,7 @@
|
||||||
{{ creating ? "Saving..." : "Save & Create Another" }}
|
{{ creating ? "Saving..." : "Save & Create Another" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button type="submit" :disabled="creating" class="btn btn-primary">
|
||||||
type="submit"
|
|
||||||
:disabled="creating"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
{{
|
{{
|
||||||
creating
|
creating
|
||||||
? "Saving..."
|
? "Saving..."
|
||||||
|
|
@ -1007,7 +958,7 @@ const saveAndCreateAnother = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header h1 {
|
.page-header h1 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -1037,7 +988,7 @@ const saveAndCreateAnother = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading {
|
.section-heading {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
<input v-model="searchQuery" placeholder="Search members..." />
|
<input v-model="searchQuery" placeholder="Search members..." />
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-bottom: 0">
|
<div class="field" style="margin-bottom: 0">
|
||||||
<select v-model="circleFilter">
|
<select v-model="circleFilter" aria-label="Filter by circle">
|
||||||
<option value="">All Circles</option>
|
<option value="">All Circles</option>
|
||||||
<option value="community">Community</option>
|
<option value="community">Community</option>
|
||||||
<option value="founder">Founder</option>
|
<option value="founder">Founder</option>
|
||||||
|
|
@ -56,6 +56,8 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-check">
|
<th class="col-check">
|
||||||
<UCheckbox
|
<UCheckbox
|
||||||
|
label="Select all members"
|
||||||
|
:ui="{ label: 'sr-only' }"
|
||||||
:model-value="
|
:model-value="
|
||||||
allVisibleSelected
|
allVisibleSelected
|
||||||
? true
|
? true
|
||||||
|
|
@ -80,6 +82,8 @@
|
||||||
<tr v-for="member in filteredMembers" :key="member._id">
|
<tr v-for="member in filteredMembers" :key="member._id">
|
||||||
<td class="col-check">
|
<td class="col-check">
|
||||||
<UCheckbox
|
<UCheckbox
|
||||||
|
:label="`Select ${member.name}`"
|
||||||
|
:ui="{ label: 'sr-only' }"
|
||||||
:model-value="selectedMemberIds.includes(member._id)"
|
:model-value="selectedMemberIds.includes(member._id)"
|
||||||
@update:model-value="toggleSelect(member._id)"
|
@update:model-value="toggleSelect(member._id)"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -403,13 +403,12 @@ const isAlmostFull = (event) => {
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
}
|
}
|
||||||
.cta-soon {
|
.cta-soon {
|
||||||
color: var(--text-faint);
|
color: var(--text-dim);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.cta-soon em {
|
.cta-soon em {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
opacity: 0.65;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-toggle {
|
.filter-toggle {
|
||||||
|
|
|
||||||
|
|
@ -256,22 +256,21 @@ const copyCalendarLink = async () => {
|
||||||
const { openLoginModal } = useLoginModal();
|
const { openLoginModal } = useLoginModal();
|
||||||
|
|
||||||
// Handle authentication check on page load
|
// Handle authentication check on page load
|
||||||
|
// server: false ensures this always runs on the client, even on a hard page load.
|
||||||
|
// The auth middleware only fires for client-side navigations in Nuxt 4, so we
|
||||||
|
// can't rely on it to open the modal when the user lands directly on this URL.
|
||||||
const { pending: authPending } = await useLazyAsyncData(
|
const { pending: authPending } = await useLazyAsyncData(
|
||||||
"dashboard-auth",
|
"dashboard-auth",
|
||||||
async () => {
|
async () => {
|
||||||
// Only check authentication on client side
|
|
||||||
if (process.server) return null;
|
|
||||||
|
|
||||||
// If no member data, try to authenticate
|
|
||||||
if (!memberData.value) {
|
if (!memberData.value) {
|
||||||
const isAuthenticated = await checkMemberStatus();
|
const isAuthenticated = await checkMemberStatus();
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Show login modal instead of redirecting
|
|
||||||
openLoginModal({
|
openLoginModal({
|
||||||
title: "Sign in to your dashboard",
|
title: "Sign in to continue",
|
||||||
description: "Enter your email to access your member dashboard",
|
description: "You need to be signed in to access this page",
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
|
redirectTo: "/member/dashboard",
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +278,7 @@ const { pending: authPending } = await useLazyAsyncData(
|
||||||
|
|
||||||
return memberData.value;
|
return memberData.value;
|
||||||
},
|
},
|
||||||
|
{ server: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load registered events
|
// Load registered events
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,10 @@
|
||||||
<div class="section-label">Visibility</div>
|
<div class="section-label">Visibility</div>
|
||||||
|
|
||||||
<div class="toggle-field">
|
<div class="toggle-field">
|
||||||
<USwitch v-model="formData.showInDirectory" />
|
<USwitch
|
||||||
|
v-model="formData.showInDirectory"
|
||||||
|
aria-label="Show in Member Directory"
|
||||||
|
/>
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Show in Member Directory
|
Show in Member Directory
|
||||||
<span class="toggle-sub"
|
<span class="toggle-sub"
|
||||||
|
|
@ -206,7 +209,10 @@
|
||||||
<div class="section-label">Peer Support</div>
|
<div class="section-label">Peer Support</div>
|
||||||
|
|
||||||
<div class="toggle-field">
|
<div class="toggle-field">
|
||||||
<USwitch v-model="formData.peerSupportEnabled" />
|
<USwitch
|
||||||
|
v-model="formData.peerSupportEnabled"
|
||||||
|
aria-label="Offer Peer Support"
|
||||||
|
/>
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Offer Peer Support
|
Offer Peer Support
|
||||||
<span class="toggle-sub"
|
<span class="toggle-sub"
|
||||||
|
|
@ -291,7 +297,10 @@
|
||||||
<div class="section-label">Notifications</div>
|
<div class="section-label">Notifications</div>
|
||||||
|
|
||||||
<div class="toggle-field">
|
<div class="toggle-field">
|
||||||
<USwitch v-model="formData.notifyEvents" />
|
<USwitch
|
||||||
|
v-model="formData.notifyEvents"
|
||||||
|
aria-label="Event reminders"
|
||||||
|
/>
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Event reminders
|
Event reminders
|
||||||
<span class="toggle-sub"
|
<span class="toggle-sub"
|
||||||
|
|
@ -301,7 +310,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-field">
|
<div class="toggle-field">
|
||||||
<USwitch v-model="formData.notifyUpdates" />
|
<USwitch
|
||||||
|
v-model="formData.notifyUpdates"
|
||||||
|
aria-label="Community updates"
|
||||||
|
/>
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Community updates
|
Community updates
|
||||||
<span class="toggle-sub"
|
<span class="toggle-sub"
|
||||||
|
|
@ -311,7 +323,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-field">
|
<div class="toggle-field">
|
||||||
<USwitch v-model="formData.notifyPeerRequests" />
|
<USwitch
|
||||||
|
v-model="formData.notifyPeerRequests"
|
||||||
|
aria-label="Peer support requests"
|
||||||
|
/>
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Peer support requests
|
Peer support requests
|
||||||
<span class="toggle-sub"
|
<span class="toggle-sub"
|
||||||
|
|
|
||||||
147
e2e/a11y.spec.js
147
e2e/a11y.spec.js
|
|
@ -1,112 +1,127 @@
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from "@playwright/test";
|
||||||
import AxeBuilder from '@axe-core/playwright'
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
import { loginAsAdmin } from './helpers/auth.js'
|
import { loginAsAdmin } from "./helpers/auth.js";
|
||||||
|
|
||||||
const publicPages = [
|
const publicPages = [
|
||||||
{ name: 'Home', path: '/' },
|
{ name: "Home", path: "/" },
|
||||||
{ name: 'Join', path: '/join' },
|
{ name: "Join", path: "/join" },
|
||||||
{ name: 'Events', path: '/events' },
|
{ name: "Events", path: "/events" },
|
||||||
{ name: 'Coming Soon', path: '/coming-soon' },
|
{ name: "Coming Soon", path: "/coming-soon" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const memberPages = [
|
const memberPages = [
|
||||||
{ name: 'Member Dashboard', path: '/member/dashboard' },
|
{ name: "Member Dashboard", path: "/member/dashboard" },
|
||||||
{ name: 'Member Profile', path: '/member/profile' },
|
{ name: "Member Profile", path: "/member/profile" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const adminPages = [
|
const adminPages = [
|
||||||
{ name: 'Admin Members', path: '/admin/members' },
|
{ name: "Admin Members", path: "/admin/members" },
|
||||||
{ name: 'Admin Events Create', path: '/admin/events/create' },
|
{ name: "Admin Events Create", path: "/admin/events/create" },
|
||||||
]
|
];
|
||||||
|
|
||||||
test.describe('accessibility — public pages', () => {
|
test.describe("accessibility — public pages", () => {
|
||||||
for (const { name, path } of publicPages) {
|
for (const { name, path } of publicPages) {
|
||||||
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
||||||
await page.goto(path)
|
await page.goto(path);
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
const results = await new AxeBuilder({ page })
|
const results = await new AxeBuilder({ page })
|
||||||
.withTags(['wcag2a', 'wcag2aa'])
|
.withTags(["wcag2a", "wcag2aa"])
|
||||||
.analyze()
|
.analyze();
|
||||||
|
|
||||||
const critical = results.violations.filter(
|
const critical = results.violations.filter(
|
||||||
(v) => v.impact === 'critical' || v.impact === 'serious'
|
(v) => v.impact === "critical" || v.impact === "serious",
|
||||||
)
|
);
|
||||||
|
|
||||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
|
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
test.describe('accessibility — member pages', () => {
|
test.describe("accessibility — member pages", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAsAdmin(page)
|
await loginAsAdmin(page);
|
||||||
})
|
});
|
||||||
|
|
||||||
for (const { name, path } of memberPages) {
|
for (const { name, path } of memberPages) {
|
||||||
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
||||||
await page.goto(path)
|
await page.goto(path);
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
const results = await new AxeBuilder({ page })
|
const results = await new AxeBuilder({ page })
|
||||||
.withTags(['wcag2a', 'wcag2aa'])
|
.withTags(["wcag2a", "wcag2aa"])
|
||||||
.analyze()
|
.analyze();
|
||||||
|
|
||||||
const critical = results.violations.filter(
|
const critical = results.violations.filter(
|
||||||
(v) => v.impact === 'critical' || v.impact === 'serious'
|
(v) => v.impact === "critical" || v.impact === "serious",
|
||||||
)
|
);
|
||||||
|
|
||||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
|
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
test.describe('accessibility — admin pages', () => {
|
test.describe("accessibility — admin pages", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAsAdmin(page)
|
await loginAsAdmin(page);
|
||||||
})
|
});
|
||||||
|
|
||||||
for (const { name, path } of adminPages) {
|
for (const { name, path } of adminPages) {
|
||||||
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
test(`${name} has no critical a11y violations`, async ({ page }) => {
|
||||||
await page.goto(path)
|
await page.goto(path);
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
const results = await new AxeBuilder({ page })
|
const results = await new AxeBuilder({ page })
|
||||||
.withTags(['wcag2a', 'wcag2aa'])
|
.withTags(["wcag2a", "wcag2aa"])
|
||||||
.analyze()
|
.analyze();
|
||||||
|
|
||||||
const critical = results.violations.filter(
|
const critical = results.violations.filter(
|
||||||
(v) => v.impact === 'critical' || v.impact === 'serious'
|
(v) => v.impact === "critical" || v.impact === "serious",
|
||||||
)
|
);
|
||||||
|
|
||||||
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
|
expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
test.describe('keyboard navigation', () => {
|
test.describe("keyboard navigation", () => {
|
||||||
test('tab through join form fields in order', async ({ page }) => {
|
test("tab through join form fields in order", async ({ page }) => {
|
||||||
await page.goto('/join')
|
await page.goto("/join");
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Focus the name field and tab through
|
// Focus the name field and tab through
|
||||||
await page.locator('#join-name').focus()
|
await page.locator("#join-name").focus();
|
||||||
expect(await page.locator('#join-name').evaluate((el) => el === document.activeElement)).toBe(true)
|
expect(
|
||||||
|
await page
|
||||||
|
.locator("#join-name")
|
||||||
|
.evaluate((el) => el === document.activeElement),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
await page.keyboard.press('Tab')
|
await page.keyboard.press("Tab");
|
||||||
// Email field should receive focus next
|
// Email field should receive focus next
|
||||||
expect(await page.locator('#join-email').evaluate((el) => el === document.activeElement)).toBe(true)
|
expect(
|
||||||
})
|
await page
|
||||||
|
.locator("#join-email")
|
||||||
|
.evaluate((el) => el === document.activeElement),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('escape closes login modal', async ({ page }) => {
|
test("escape closes login modal", async ({ page }) => {
|
||||||
await page.goto('/member/dashboard')
|
await page.goto("/member/dashboard");
|
||||||
// Wait for login modal to appear
|
|
||||||
const modal = page.locator('text=Sign in to continue').or(page.locator('text=Sign in to your dashboard'))
|
|
||||||
await expect(modal.first()).toBeVisible({ timeout: 10000 })
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape')
|
// The page renders an inline "sign in required" wall for unauthenticated users
|
||||||
|
const signInBlock = page.locator("h2", { hasText: "Sign in required" });
|
||||||
|
await expect(signInBlock).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click the Sign In button to open the login modal overlay
|
||||||
|
await page.locator("button", { hasText: "Sign In" }).click();
|
||||||
|
|
||||||
|
const modal = page.locator("text=Sign in to your dashboard");
|
||||||
|
await expect(modal.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
// Modal should close
|
// Modal should close
|
||||||
await expect(modal.first()).not.toBeVisible({ timeout: 5000 })
|
await expect(modal.first()).not.toBeVisible({ timeout: 5000 });
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,53 @@
|
||||||
import { test, expect } from './helpers/fixtures.js'
|
import { test, expect } from "./helpers/fixtures.js";
|
||||||
|
|
||||||
test.describe('Admin members page', () => {
|
test.describe("Admin members page", () => {
|
||||||
test('members list loads for admin', async ({ adminPage }) => {
|
test("members list loads for admin", async ({ adminPage }) => {
|
||||||
await adminPage.goto('/admin/members')
|
await adminPage.goto("/admin/members");
|
||||||
|
|
||||||
await expect(adminPage.locator('h1')).toHaveText('Members')
|
await expect(adminPage.locator("h1")).toHaveText("Members");
|
||||||
await expect(adminPage.getByText('Manage members, contributions, and access')).toBeVisible()
|
await expect(
|
||||||
})
|
adminPage.getByText("Manage members, contributions, and access"),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('search bar works', async ({ adminPage }) => {
|
test("search bar works", async ({ adminPage }) => {
|
||||||
await adminPage.goto('/admin/members')
|
await adminPage.goto("/admin/members");
|
||||||
|
|
||||||
const searchInput = adminPage.getByPlaceholder('Search members...')
|
const searchInput = adminPage.getByPlaceholder("Search members...");
|
||||||
await expect(searchInput).toBeVisible()
|
await expect(searchInput).toBeVisible();
|
||||||
|
|
||||||
await searchInput.fill('nonexistent-query-xyz')
|
await searchInput.fill("nonexistent-query-xyz");
|
||||||
|
|
||||||
// Page should not crash -- either shows filtered results or the empty state
|
// Page should not crash -- either shows filtered results or the empty state
|
||||||
await expect(
|
await expect(
|
||||||
adminPage.locator('table').or(adminPage.getByText('No members found matching your criteria'))
|
adminPage
|
||||||
).toBeVisible()
|
.locator("table")
|
||||||
})
|
.or(adminPage.getByText("No members found matching your criteria")),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('non-admin redirect', async ({ browser }) => {
|
test("non-admin redirect", async ({ browser }) => {
|
||||||
const context = await browser.newContext()
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage()
|
const page = await context.newPage();
|
||||||
|
|
||||||
await page.goto('/admin/members')
|
await page.goto("/admin/members");
|
||||||
|
|
||||||
// Admin middleware redirects non-admin users to / or /members
|
// Admin middleware redirects non-admin users to / or /members
|
||||||
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
await page.waitForURL((url) => !url.pathname.startsWith("/admin"));
|
||||||
expect(page.url()).not.toContain('/admin/members')
|
expect(page.url()).not.toContain("/admin/members");
|
||||||
|
|
||||||
await context.close()
|
await context.close();
|
||||||
})
|
});
|
||||||
|
|
||||||
test('add member button opens modal', async ({ adminPage }) => {
|
test("add member button opens modal", async ({ adminPage }) => {
|
||||||
await adminPage.goto('/admin/members')
|
await adminPage.goto("/admin/members");
|
||||||
|
await adminPage.waitForLoadState("networkidle"); // ensure Vue hydration is complete
|
||||||
|
|
||||||
await adminPage.getByRole('button', { name: 'Add Member' }).click()
|
await adminPage.getByRole("button", { name: "Add Member" }).click();
|
||||||
|
|
||||||
// Modal should appear with the form heading and fields
|
// Modal should appear with the form heading and fields
|
||||||
await expect(adminPage.getByText('Add New Member')).toBeVisible()
|
await expect(adminPage.getByText("Add New Member")).toBeVisible();
|
||||||
await expect(adminPage.getByPlaceholder('Full name')).toBeVisible()
|
await expect(adminPage.getByPlaceholder("Full name")).toBeVisible();
|
||||||
await expect(adminPage.getByPlaceholder('email@example.com')).toBeVisible()
|
await expect(adminPage.getByPlaceholder("email@example.com")).toBeVisible();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,45 @@
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
test.describe('coming-soon page', () => {
|
test.describe("coming-soon page", () => {
|
||||||
test('renders with heading and login form', async ({ page }) => {
|
test("renders with heading and login form", async ({ page }) => {
|
||||||
await page.goto('/coming-soon')
|
await page.goto("/coming-soon");
|
||||||
|
|
||||||
await expect(page.locator('h1')).toContainText('Ghost Guild')
|
await expect(page.locator("h1")).toContainText("Ghost Guild");
|
||||||
await expect(page.locator('input[type="email"]')).toBeVisible()
|
await expect(page.locator('input[type="email"]')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Send Magic Link' })).toBeVisible()
|
await expect(
|
||||||
})
|
page.getByRole("button", { name: "Send Magic Link" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('shows "Coming Soon" text for unauthenticated visitors', async ({ page }) => {
|
test('shows "Coming Soon" text for unauthenticated visitors', async ({
|
||||||
await page.goto('/coming-soon')
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/coming-soon");
|
||||||
|
|
||||||
await expect(page.getByText('Coming Soon')).toBeVisible()
|
await expect(page.getByText("Coming Soon")).toBeVisible();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
test.describe('public routes accessible when gate is off', () => {
|
test.describe("public routes accessible when gate is off", () => {
|
||||||
test('home page loads', async ({ page }) => {
|
test("home page loads", async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto("/");
|
||||||
|
|
||||||
// Should not redirect to /coming-soon
|
// Should not redirect to /coming-soon
|
||||||
expect(page.url()).not.toContain('/coming-soon')
|
expect(page.url()).not.toContain("/coming-soon");
|
||||||
await expect(page.getByText('Ghost Guild')).toBeVisible()
|
await expect(page.locator("h1")).toContainText("Ghost Guild");
|
||||||
})
|
});
|
||||||
|
|
||||||
test('events page loads', async ({ page }) => {
|
test("events page loads", async ({ page }) => {
|
||||||
await page.goto('/events')
|
await page.goto("/events");
|
||||||
|
|
||||||
expect(page.url()).not.toContain('/coming-soon')
|
expect(page.url()).not.toContain("/coming-soon");
|
||||||
await expect(page.locator('h1')).toContainText('Events')
|
await expect(page.locator("h1")).toContainText("Events");
|
||||||
})
|
});
|
||||||
|
|
||||||
test('join page loads', async ({ page }) => {
|
test("join page loads", async ({ page }) => {
|
||||||
await page.goto('/join')
|
await page.goto("/join");
|
||||||
|
|
||||||
expect(page.url()).not.toContain('/coming-soon')
|
expect(page.url()).not.toContain("/coming-soon");
|
||||||
await expect(page.locator('h1')).toContainText('Join Ghost Guild')
|
await expect(page.locator("h1")).toContainText("Join Ghost Guild");
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ export default defineNuxtConfig({
|
||||||
classSuffix: "",
|
classSuffix: "",
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
head: {},
|
head: {
|
||||||
|
htmlAttrs: { lang: "en" },
|
||||||
|
title: "Ghost Guild",
|
||||||
|
titleTemplate: "%s · Ghost Guild",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
transpile: ["vue-cal"],
|
transpile: ["vue-cal"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue