feat(admin-events): form layout overhaul + agenda input + date input rewrite

Admin event create form:
- Wraps body in a form-layout/form-main container for upcoming sidebar work.
- Bigger autoresize textareas for description/content; adds an Agenda
  textarea (one item per line, persisted as event.agenda).
- Reorganises settings into Event Settings + conditional Cancellation
  Message sections.
- Pulls event-type options from EVENT_TYPES; location becomes optional;
  passes displayTimezone through to NaturalDateInput.

NaturalDateInput: rewritten to a single always-visible UInput with chrono
parsing and trailing status icon, instead of toggling between input and
parsed-summary blocks. Cleaner state model (rawInput / parsedDate /
isValid / hasError) and timezone-aware update emission.
This commit is contained in:
Jennie Robinson Faber 2026-05-21 17:50:56 +01:00
parent 622cc8e53b
commit 4a05e91715
2 changed files with 474 additions and 514 deletions

View file

@ -1,80 +1,40 @@
<template> <template>
<div class="space-y-2"> <div class="natural-date-input">
<div v-if="!parsedDate || hasError || isEditing" class="relative"> <UInput
<UInput :model-value="rawInput"
:model-value="naturalInput" :placeholder="placeholder"
:placeholder="placeholder" :color="trailingState"
:color=" @update:model-value="onInputChange"
hasError && naturalInput.trim()
? 'error'
: isValidParse && naturalInput.trim()
? 'success'
: undefined
"
@update:model-value="onNaturalInputChange"
@blur="onBlur"
>
<template #trailing>
<Icon
v-if="isValidParse && naturalInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5"
style="color: var(--candle)"
/>
<Icon
v-else-if="hasError && naturalInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5"
style="color: var(--ember)"
/>
</template>
</UInput>
</div>
<div
v-if="parsedDate && isValidParse && !isEditing"
class="text-sm px-3 py-2"
style="color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
> >
<div class="flex items-center justify-between gap-2"> <template #trailing>
<div class="flex items-center gap-2"> <Icon
<Icon name="heroicons:calendar" class="w-4 h-4" /> v-if="isValid && rawInput.trim()"
<span>{{ formatParsedDate(parsedDate) }}</span> name="heroicons:check-circle"
</div> class="w-5 h-5"
<button style="color: var(--candle)"
type="button"
class="edit-link"
@click="clearAndEdit"
>
Edit
</button>
</div>
</div>
<div
v-if="hasError && naturalInput.trim()"
class="text-sm px-3 py-2"
style="color: var(--ember); background: color-mix(in srgb, var(--ember) 15%, transparent); border: 1px solid var(--ember)"
>
<div class="flex items-center gap-2">
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Fallback datetime-local input -->
<details class="text-sm">
<summary class="cursor-pointer" style="color: var(--text-dim)">
Use traditional date picker
</summary>
<div class="mt-2">
<UInput
v-model="datetimeValue"
type="datetime-local"
@change="onDatetimeChange"
/> />
</div> <Icon
</details> v-else-if="hasError && rawInput.trim()"
name="heroicons:exclamation-circle"
class="w-5 h-5"
style="color: var(--ember)"
/>
</template>
</UInput>
<p
v-if="rawInput.trim() && isValid"
class="preview-line"
style="color: var(--candle)"
>
&rarr; {{ previewText }}
</p>
<p
v-else-if="rawInput.trim() && hasError"
class="preview-line"
style="color: var(--ember)"
>
{{ errorMessage }}
</p>
</div> </div>
</template> </template>
@ -82,227 +42,197 @@
import * as chrono from "chrono-node"; import * as chrono from "chrono-node";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: { type: String, default: "" },
type: String,
default: "",
},
placeholder: { placeholder: {
type: String, type: String,
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"', default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"',
},
inputClass: {
type: [String, Object],
default: "",
},
required: {
type: Boolean,
default: false,
}, },
displayTimezone: { type: String, default: "" },
required: { type: Boolean, default: false },
}); });
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const naturalInput = ref(""); const rawInput = ref("");
const parsedDate = ref(null); const isValid = ref(false);
const isValidParse = ref(false);
const hasError = ref(false); const hasError = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const datetimeValue = ref(""); // previewDate holds the parsed value as a UTC Date so we can format it in
const isEditing = ref(false); // arbitrary timezones without re-parsing. Source of truth for the preview.
const previewDate = ref(null);
// Initialize with current value const trailingState = computed(() => {
onMounted(() => { if (!rawInput.value.trim()) return undefined;
if (props.modelValue) { if (hasError.value) return "error";
const date = new Date(props.modelValue); if (isValid.value) return "success";
if (!isNaN(date.getTime())) { return undefined;
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
}
}
}); });
// Watch for external changes to modelValue const previewText = computed(() => {
watch( if (!previewDate.value) return "";
() => props.modelValue, const tz = activeTZ();
(newValue) => { const date = new Intl.DateTimeFormat("en-US", {
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) { timeZone: tz,
const date = new Date(newValue); weekday: "short",
if (!isNaN(date.getTime())) { month: "short",
parsedDate.value = date; day: "numeric",
datetimeValue.value = formatForDatetimeLocal(date); year: "numeric",
isValidParse.value = true;
naturalInput.value = ""; // Clear natural input when set externally
isEditing.value = false;
}
} else if (!newValue) {
reset();
}
},
);
const parseNaturalInput = () => {
const input = naturalInput.value.trim();
if (!input) {
reset();
return;
}
try {
// Parse with chrono-node
const results = chrono.parse(input);
if (results.length > 0) {
const result = results[0];
const date = result.date();
// Validate the parsed date
if (date && !isNaN(date.getTime())) {
parsedDate.value = date;
isValidParse.value = true;
hasError.value = false;
datetimeValue.value = formatForDatetimeLocal(date);
emit("update:modelValue", formatForDatetimeLocal(date));
} else {
setError("Could not parse this date format");
}
} else {
setError(
'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"',
);
}
} catch (error) {
setError("Error parsing date");
}
};
// UInput's fall-through @input listener fires unreliably (the Nuxt UI Input
// template merges $attrs with an explicit @input and can drop keystrokes).
// update:model-value is a declared emit and fires for every change.
const onNaturalInputChange = (value) => {
naturalInput.value = value;
parseNaturalInput();
};
const onBlur = () => {
// Always re-parse on blur so the final typed value wins, even if an
// intermediate state produced a stale parse.
if (naturalInput.value.trim()) {
parseNaturalInput();
}
if (isValidParse.value) {
isEditing.value = false;
}
};
const onDatetimeChange = () => {
if (datetimeValue.value) {
const date = new Date(datetimeValue.value);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
isValidParse.value = true;
hasError.value = false;
naturalInput.value = ""; // Clear natural input when using traditional picker
isEditing.value = false;
emit("update:modelValue", datetimeValue.value);
}
} else {
reset();
}
};
const reset = () => {
parsedDate.value = null;
isValidParse.value = false;
hasError.value = false;
errorMessage.value = "";
emit("update:modelValue", "");
};
const setError = (message) => {
isValidParse.value = false;
hasError.value = true;
errorMessage.value = message;
parsedDate.value = null;
};
const clearAndEdit = () => {
// Pre-fill the input with a readable string so the user doesn't start over.
naturalInput.value = parsedDate.value
? parsedDate.value.toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
: "";
parsedDate.value = null;
isValidParse.value = false;
hasError.value = false;
// Prevent auto-hide while editing: chrono re-parses the pre-filled string
// on the first keystroke, which would otherwise hide the input.
isEditing.value = true;
};
const formatForDatetimeLocal = (date) => {
if (!date) return "";
// Format as YYYY-MM-DDTHH:MM for datetime-local input
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const formatParsedDate = (date) => {
if (!date) return "";
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = date.toDateString() === tomorrow.toDateString();
const timeStr = date.toLocaleString("en-US", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
hour12: true, hour12: true,
}); }).format(previewDate.value);
const abbr = shortTimezoneName(previewDate.value, tz);
return abbr ? `${date} ${abbr}` : date;
});
if (isToday) { const activeTZ = () =>
return `Today at ${timeStr}`; props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
} else if (isTomorrow) {
return `Tomorrow at ${timeStr}`; // Seed the input from modelValue without triggering chrono. The parent's
} else { // value is canonical we just render it as a chrono-friendly readable
return date.toLocaleString("en-US", { // string so the user can backspace and tweak in place.
weekday: "long", const seedFromModelValue = () => {
year: "numeric", if (!props.modelValue) {
month: "long", rawInput.value = "";
day: "numeric", isValid.value = false;
hour: "numeric", hasError.value = false;
minute: "2-digit", errorMessage.value = "";
hour12: true, previewDate.value = null;
}); return;
} }
const tz = activeTZ();
const utc = zonedLocalToUTC(props.modelValue, tz);
if (!utc) return;
previewDate.value = utc;
isValid.value = true;
hasError.value = false;
errorMessage.value = "";
rawInput.value = readableSeed(utc, tz);
};
onMounted(seedFromModelValue);
watch(
() => props.modelValue,
(next) => {
const tz = activeTZ();
const expected = previewDate.value
? utcToZonedLocal(previewDate.value, tz)
: "";
if (next === expected) return;
seedFromModelValue();
},
);
watch(
() => props.displayTimezone,
() => {
// Re-interpret the current input under the new TZ so the preview and
// emitted value reflect the new timezone semantics.
if (rawInput.value.trim()) parse(rawInput.value);
},
);
const onInputChange = (value) => {
rawInput.value = value;
parse(value);
};
const parse = (input) => {
const trimmed = input.trim();
if (!trimmed) {
isValid.value = false;
hasError.value = false;
errorMessage.value = "";
previewDate.value = null;
emit("update:modelValue", "");
return;
}
const tz = activeTZ();
let results;
try {
results = chrono.parse(trimmed, referenceNowInTZ(tz));
} catch {
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
return;
}
if (!results.length) {
setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\"");
return;
}
const date = results[0].date();
if (!date || Number.isNaN(date.getTime())) {
setError("Couldn't read that date");
return;
}
// chrono returned a Date whose browser-local components match what the
// user typed in the event timezone (because we shifted the reference).
// Read those components as wall-clock in displayTimezone.
const localStr = browserComponentsToString(date);
const utc = zonedLocalToUTC(localStr, tz);
if (!utc) {
setError("Couldn't parse this date");
return;
}
isValid.value = true;
hasError.value = false;
errorMessage.value = "";
previewDate.value = utc;
emit("update:modelValue", localStr);
};
const setError = (msg) => {
isValid.value = false;
hasError.value = true;
errorMessage.value = msg;
previewDate.value = null;
emit("update:modelValue", "");
};
// Build a Date object whose browser-local components equal the current
// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next
// Friday" anchor to the event TZ rather than the editor's browser TZ.
const referenceNowInTZ = (tz) => {
const nowStr = utcToZonedLocal(new Date(), tz);
if (!nowStr) return new Date();
const [d, t] = nowStr.split("T");
const [y, mo, day] = d.split("-").map(Number);
const [h, mi] = t.split(":").map(Number);
return new Date(y, mo - 1, day, h, mi);
};
const browserComponentsToString = (date) => {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${d}T${h}:${mi}`;
};
const readableSeed = (utc, tz) => {
// Format chosen to round-trip cleanly through chrono.parse.
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(utc);
}; };
</script> </script>
<style scoped> <style scoped>
.edit-link { .natural-date-input {
background: none; display: flex;
border: none; flex-direction: column;
color: var(--candle); gap: 6px;
cursor: pointer;
font-family: "Commit Mono", monospace;
font-size: 11px;
padding: 0;
text-decoration: underline;
} }
.edit-link:hover {
color: var(--ember); .preview-line {
font-size: 12px;
margin: 0;
} }
</style> </style>

View file

@ -27,6 +27,8 @@
</div> </div>
<form @submit.prevent="saveEvent"> <form @submit.prevent="saveEvent">
<div class="form-layout">
<div class="form-main">
<!-- Basic Information --> <!-- Basic Information -->
<div class="form-section"> <div class="form-section">
<h2 class="section-heading">Basic Information</h2> <h2 class="section-heading">Basic Information</h2>
@ -38,6 +40,7 @@
placeholder="Enter a clear, descriptive event title" placeholder="Enter a clear, descriptive event title"
required required
:color="fieldErrors.title ? 'error' : undefined" :color="fieldErrors.title ? 'error' : undefined"
:ui="{ base: 'title-input' }"
class="w-full" class="w-full"
/> />
<p v-if="fieldErrors.title" class="field-error"> <p v-if="fieldErrors.title" class="field-error">
@ -60,7 +63,8 @@
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"
required required
:rows="4" :rows="8"
autoresize
:color="fieldErrors.description ? 'error' : undefined" :color="fieldErrors.description ? 'error' : undefined"
class="w-full" class="w-full"
/> />
@ -77,7 +81,8 @@
<UTextarea <UTextarea
v-model="eventForm.content" v-model="eventForm.content"
placeholder="Add detailed information, agenda, requirements, or other important details" placeholder="Add detailed information, agenda, requirements, or other important details"
:rows="6" :rows="12"
autoresize
class="w-full" class="w-full"
/> />
<p class="help-text"> <p class="help-text">
@ -85,6 +90,21 @@
requirements requirements
</p> </p>
</div> </div>
<div class="field">
<label>Event Agenda</label>
<UTextarea
v-model="agendaText"
placeholder="Introduction and welcome - 10 mins&#10;Main talk - 30 mins&#10;Q&amp;A - 15 mins"
:rows="6"
autoresize
class="w-full"
/>
<p class="help-text">
One agenda item per line. Help attendees know what to expect
during the event.
</p>
</div>
</div> </div>
<!-- Event Details --> <!-- Event Details -->
@ -97,12 +117,7 @@
<USelect <USelect
v-model="eventForm.eventType" v-model="eventForm.eventType"
aria-label="Event type" aria-label="Event type"
:items="[ :items="EVENT_TYPES"
{ label: 'Community Meetup', value: 'community' },
{ label: 'Workshop', value: 'workshop' },
{ label: 'Social Event', value: 'social' },
{ label: 'Showcase', value: 'showcase' },
]"
class="w-full" class="w-full"
/> />
<p class="help-text"> <p class="help-text">
@ -128,18 +143,13 @@
</div> </div>
<div class="field"> <div class="field">
<label> Location <span class="required">*</span> </label> <label>Location</label>
<UInput <UInput
v-model="eventForm.location" v-model="eventForm.location"
placeholder="e.g., https://zoom.us/j/123..., #channel-name, or TBD" placeholder="e.g., https://zoom.us/j/123..., #channel-name, or TBD"
required
:color="fieldErrors.location ? 'error' : undefined"
class="w-full" class="w-full"
/> />
<p v-if="fieldErrors.location" class="field-error"> <p class="help-text">
{{ fieldErrors.location }}
</p>
<p v-if="!fieldErrors.location" class="help-text">
Video conference link, Slack channel (#channel-name), or 'TBD' if Video conference link, Slack channel (#channel-name), or 'TBD' if
the platform is undecided the platform is undecided
</p> </p>
@ -149,6 +159,7 @@
<label> Start Date & Time <span class="required">*</span> </label> <label> Start Date & Time <span class="required">*</span> </label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.startDate" v-model="eventForm.startDate"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'" placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
:required="true" :required="true"
/> />
@ -161,6 +172,7 @@
<label> End Date & Time <span class="required">*</span> </label> <label> End Date & Time <span class="required">*</span> </label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.endDate" v-model="eventForm.endDate"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'" placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
:required="true" :required="true"
/> />
@ -187,6 +199,7 @@
<label>Registration Deadline</label> <label>Registration Deadline</label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.registrationDeadline" v-model="eventForm.registrationDeadline"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., 'tomorrow at noon', '1 hour before event'" placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
/> />
<p class="help-text"> <p class="help-text">
@ -196,6 +209,87 @@
</div> </div>
</div> </div>
<!-- Event Settings -->
<div class="form-section">
<h2 class="section-heading">Event Settings</h2>
<div class="form-grid">
<div class="check-group">
<label class="check-label">
<input v-model="eventForm.isOnline" type="checkbox" >
<div>
<strong>Online Event</strong>
<span class="help-text">
Event will be conducted virtually
</span>
</div>
</label>
<label class="check-label">
<input
v-model="eventForm.registrationRequired"
type="checkbox"
>
<div>
<strong>Registration Required</strong>
<span class="help-text">
Attendees must register before attending
</span>
</div>
</label>
</div>
<div class="check-group">
<label class="check-label">
<input v-model="eventForm.isVisible" type="checkbox" >
<div>
<strong>Visible on Public Calendar</strong>
<span class="help-text">
Event will appear on the public events page
</span>
</div>
</label>
<label class="check-label">
<input v-model="eventForm.isCancelled" type="checkbox" >
<div>
<strong>Event Cancelled</strong>
<span class="help-text"> Mark this event as cancelled </span>
</div>
</label>
<label class="check-label">
<input v-model="eventForm.membersOnly" type="checkbox" >
<div>
<strong>Members Only</strong>
<span class="help-text">
Hide this event from the public; only members can see it
</span>
</div>
</label>
</div>
</div>
</div>
<!-- Cancellation Message (conditional) -->
<div v-if="eventForm.isCancelled" class="form-section">
<div class="field">
<label>Cancellation Message</label>
<UTextarea
v-model="eventForm.cancellationMessage"
placeholder="Explain why the event was cancelled and any next steps..."
:rows="3"
color="error"
class="w-full"
/>
<p class="help-text">
This message will be displayed to users viewing the event page
</p>
</div>
</div>
</div>
<aside class="form-aside">
<!-- Target Audience --> <!-- Target Audience -->
<div class="form-section"> <div class="form-section">
<h2 class="section-heading">Target Audience</h2> <h2 class="section-heading">Target Audience</h2>
@ -208,39 +302,24 @@
v-model="eventForm.targetCircles" v-model="eventForm.targetCircles"
value="community" value="community"
type="checkbox" type="checkbox"
/> >
<div> <strong>Community Circle</strong>
<strong>Community Circle</strong>
<span class="help-text">
New members and those exploring the community
</span>
</div>
</label> </label>
<label class="check-label"> <label class="check-label">
<input <input
v-model="eventForm.targetCircles" v-model="eventForm.targetCircles"
value="founder" value="founder"
type="checkbox" type="checkbox"
/> >
<div> <strong>Founder Circle</strong>
<strong>Founder Circle</strong>
<span class="help-text">
Entrepreneurs and business leaders
</span>
</div>
</label> </label>
<label class="check-label"> <label class="check-label">
<input <input
v-model="eventForm.targetCircles" v-model="eventForm.targetCircles"
value="practitioner" value="practitioner"
type="checkbox" type="checkbox"
/> >
<div> <strong>Practitioner Circle</strong>
<strong>Practitioner Circle</strong>
<span class="help-text">
Experts and professionals sharing knowledge
</span>
</div>
</label> </label>
</div> </div>
<p class="help-text"> <p class="help-text">
@ -261,11 +340,30 @@
:items="tagOptions" :items="tagOptions"
value-key="value" value-key="value"
multiple multiple
placeholder="Select tags..." searchable
create-item
placeholder="Select or type to add tags..."
class="w-full" class="w-full"
@create="onTagCreate"
/> />
<div class="field new-tag-pool">
<label>New tag pool</label>
<USelect
v-model="newTagPool"
:items="[
{ label: 'Cooperative', value: 'cooperative' },
{ label: 'Craft', value: 'craft' },
]"
value-key="value"
class="w-full"
/>
<p class="help-text">
Pool assigned to any new tag you create from this field.
</p>
</div>
<p class="help-text"> <p class="help-text">
Tag this event to help with discovery and recommendations Tag this event to help with discovery and recommendations. Type a
new tag and press enter to add it.
</p> </p>
</div> </div>
</div> </div>
@ -275,7 +373,7 @@
<h2 class="section-heading">Ticketing</h2> <h2 class="section-heading">Ticketing</h2>
<label class="check-label"> <label class="check-label">
<input v-model="eventForm.tickets.enabled" type="checkbox" /> <input v-model="eventForm.tickets.enabled" type="checkbox" >
<div> <div>
<strong>Enable Ticketing</strong> <strong>Enable Ticketing</strong>
<span class="help-text"> Allow ticket sales for this event </span> <span class="help-text"> Allow ticket sales for this event </span>
@ -287,7 +385,7 @@
<input <input
v-model="eventForm.tickets.public.available" v-model="eventForm.tickets.public.available"
type="checkbox" type="checkbox"
/> >
<div> <div>
<strong>Public Tickets Available</strong> <strong>Public Tickets Available</strong>
<span class="help-text"> <span class="help-text">
@ -369,6 +467,7 @@
<label>Early Bird Deadline</label> <label>Early Bird Deadline</label>
<NaturalDateInput <NaturalDateInput
v-model="eventForm.tickets.public.earlyBirdDeadline" v-model="eventForm.tickets.public.earlyBirdDeadline"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., '1 week before event', 'next Monday'" placeholder="e.g., '1 week before event', 'next Monday'"
/> />
<p class="help-text"> <p class="help-text">
@ -384,7 +483,7 @@
<h2 class="section-heading">Series Management</h2> <h2 class="section-heading">Series Management</h2>
<label class="check-label"> <label class="check-label">
<input v-model="eventForm.series.isSeriesEvent" type="checkbox" /> <input 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">
@ -400,7 +499,6 @@
<USelect <USelect
v-model="selectedSeriesId" v-model="selectedSeriesId"
aria-label="Select series" aria-label="Select series"
@update:model-value="onSeriesSelect"
:items=" :items="
availableSeries.map((series) => ({ availableSeries.map((series) => ({
label: `${series.title} (${series.eventCount || 0} events)`, label: `${series.title} (${series.eventCount || 0} events)`,
@ -410,6 +508,7 @@
placeholder="Choose existing series or create new..." placeholder="Choose existing series or create new..."
value-key="value" value-key="value"
class="w-full" class="w-full"
@update:model-value="onSeriesSelect"
/> />
<NuxtLink to="/admin/series/create" class="btn btn-primary"> <NuxtLink to="/admin/series/create" class="btn btn-primary">
New Series New Series
@ -467,123 +566,7 @@
</div> </div>
</div> </div>
</div> </div>
</aside>
<!-- Event Agenda -->
<div class="form-section">
<h2 class="section-heading">Event Agenda</h2>
<div class="agenda-items">
<div
v-for="(item, index) in eventForm.agenda"
:key="index"
class="agenda-row"
>
<UInput
v-model="eventForm.agenda[index]"
placeholder="Enter agenda item (e.g., 'Introduction and welcome - 10 mins')"
class="w-full"
/>
<button
type="button"
@click="removeAgendaItem(index)"
class="link-btn link-btn-danger"
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</button>
</div>
<button
type="button"
@click="addAgendaItem"
class="btn add-agenda-btn"
>
+ Add Agenda Item
</button>
</div>
<p class="help-text">
Add agenda items to help attendees know what to expect during the
event
</p>
</div>
<!-- Event Settings -->
<div class="form-section">
<h2 class="section-heading">Event Settings</h2>
<div class="form-grid">
<div class="check-group">
<label class="check-label">
<input v-model="eventForm.isOnline" type="checkbox" />
<div>
<strong>Online Event</strong>
<span class="help-text">
Event will be conducted virtually
</span>
</div>
</label>
<label class="check-label">
<input
v-model="eventForm.registrationRequired"
type="checkbox"
/>
<div>
<strong>Registration Required</strong>
<span class="help-text">
Attendees must register before attending
</span>
</div>
</label>
</div>
<div class="check-group">
<label class="check-label">
<input v-model="eventForm.isVisible" type="checkbox" />
<div>
<strong>Visible on Public Calendar</strong>
<span class="help-text">
Event will appear on the public events page
</span>
</div>
</label>
<label class="check-label">
<input v-model="eventForm.isCancelled" type="checkbox" />
<div>
<strong>Event Cancelled</strong>
<span class="help-text"> Mark this event as cancelled </span>
</div>
</label>
<label class="check-label">
<input v-model="eventForm.membersOnly" type="checkbox" />
<div>
<strong>Members Only</strong>
<span class="help-text">
Hide this event from the public; only members can see it
</span>
</div>
</label>
</div>
</div>
</div>
<!-- Cancellation Message (conditional) -->
<div v-if="eventForm.isCancelled" class="form-section">
<div class="field">
<label>Cancellation Message</label>
<UTextarea
v-model="eventForm.cancellationMessage"
placeholder="Explain why the event was cancelled and any next steps..."
:rows="3"
color="error"
class="w-full"
/>
<p class="help-text">
This message will be displayed to users viewing the event page
</p>
</div>
</div> </div>
<!-- Form Actions --> <!-- Form Actions -->
@ -594,9 +577,9 @@
<button <button
v-if="!editingEvent" v-if="!editingEvent"
type="button" type="button"
@click="saveAndCreateAnother"
:disabled="creating" :disabled="creating"
class="btn" class="btn"
@click="saveAndCreateAnother"
> >
{{ creating ? "Saving..." : "Save & Create Another" }} {{ creating ? "Saving..." : "Save & Create Another" }}
</button> </button>
@ -619,6 +602,7 @@
<script setup> <script setup>
import { TIMEZONE_OPTIONS } from "~/config/timezones"; import { TIMEZONE_OPTIONS } from "~/config/timezones";
import { EVENT_TYPES } from "~/config/eventTypes";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
@ -638,9 +622,32 @@ const availableSeries = ref([]);
const availableTags = ref([]); const availableTags = ref([]);
const tagOptions = computed(() => const tagOptions = computed(() =>
availableTags.value.map((t) => ({ label: t.label, value: t.slug })) availableTags.value.map((t) => ({ label: t.label, value: t.slug })),
); );
const newTagPool = ref("cooperative");
const onTagCreate = async (item) => {
const label = typeof item === "string" ? item : item?.label || item?.value;
if (!label?.trim()) return;
try {
const { tag } = await $fetch("/api/admin/tags", {
method: "POST",
body: { label: label.trim(), pool: newTagPool.value },
});
if (!availableTags.value.some((t) => t.slug === tag.slug)) {
availableTags.value.push({ slug: tag.slug, label: tag.label });
}
if (!eventForm.tags.includes(tag.slug)) {
eventForm.tags.push(tag.slug);
}
} catch (err) {
formErrors.value.push(
`Failed to create tag "${label}": ${err?.data?.statusMessage || err?.statusMessage || err?.message || "unknown error"}`,
);
}
};
const eventForm = reactive({ const eventForm = reactive({
title: "", title: "",
description: "", description: "",
@ -648,7 +655,7 @@ const eventForm = reactive({
featureImage: null, featureImage: null,
startDate: "", startDate: "",
endDate: "", endDate: "",
eventType: "community", eventType: "community-meetup",
displayTimezone: "America/Toronto", displayTimezone: "America/Toronto",
location: "", location: "",
isOnline: true, isOnline: true,
@ -724,14 +731,14 @@ const timezoneItems = computed(() => {
return list; return list;
}); });
// Agenda management functions const agendaText = computed({
const addAgendaItem = () => { get() {
eventForm.agenda.push(""); return (eventForm.agenda || []).join("\n");
}; },
set(v) {
const removeAgendaItem = (index) => { eventForm.agenda = v.split("\n");
eventForm.agenda.splice(index, 1); },
}; });
// Load available series and tags // Load available series and tags
onMounted(async () => { onMounted(async () => {
@ -895,12 +902,6 @@ const validateForm = () => {
fieldErrors.value.endDate = "Please select when the event ends"; fieldErrors.value.endDate = "Please select when the event ends";
} }
if (!eventForm.location.trim()) {
formErrors.value.push("Location is required");
fieldErrors.value.location =
"Please enter a URL, Slack channel, or 'TBD'";
}
// Date validation // Date validation
if (eventForm.startDate && eventForm.endDate) { if (eventForm.startDate && eventForm.endDate) {
const startDate = new Date(eventForm.startDate); const startDate = new Date(eventForm.startDate);
@ -917,22 +918,6 @@ const validateForm = () => {
} }
} }
// Location format validation
if (eventForm.location.trim()) {
const value = eventForm.location.trim();
const urlPattern = /^https?:\/\/.+/;
const slackPattern = /^#[a-zA-Z0-9-_]+$/;
const isTbd = value.toUpperCase() === "TBD";
if (!isTbd && !urlPattern.test(value) && !slackPattern.test(value)) {
formErrors.value.push(
"Location must be a valid URL, Slack channel (starting with #), or 'TBD'",
);
fieldErrors.value.location =
"Enter a URL (https://...), Slack channel (#channel-name), or 'TBD' if undecided";
}
}
// Registration deadline validation // Registration deadline validation
if (eventForm.registrationDeadline && eventForm.startDate) { if (eventForm.registrationDeadline && eventForm.startDate) {
const regDeadline = new Date(eventForm.registrationDeadline); const regDeadline = new Date(eventForm.registrationDeadline);
@ -979,6 +964,9 @@ const saveEvent = async (redirect = true) => {
registrationDeadline: eventForm.registrationDeadline registrationDeadline: eventForm.registrationDeadline
? toUTC(eventForm.registrationDeadline) ? toUTC(eventForm.registrationDeadline)
: eventForm.registrationDeadline, : eventForm.registrationDeadline,
agenda: (eventForm.agenda || [])
.map((l) => l.trim())
.filter(Boolean),
tickets: { tickets: {
...eventForm.tickets, ...eventForm.tickets,
public: { public: {
@ -1036,7 +1024,7 @@ const saveAndCreateAnother = async () => {
featureImage: null, featureImage: null,
startDate: "", startDate: "",
endDate: "", endDate: "",
eventType: "community", eventType: "community-meetup",
displayTimezone: "America/Toronto", displayTimezone: "America/Toronto",
location: "", location: "",
isOnline: true, isOnline: true,
@ -1082,7 +1070,42 @@ const saveAndCreateAnother = async () => {
<style scoped> <style scoped>
.create-form { .create-form {
max-width: 800px; display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
}
/* Vertical divider between main + aside, full viewport height */
.create-form::after {
content: "";
position: fixed;
top: 0;
bottom: 0;
right: 340px;
border-left: 1px dashed var(--border);
pointer-events: none;
z-index: 1;
}
.form-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
align-items: stretch;
flex: 1;
}
.form-main {
min-width: 0;
padding: 24px 28px;
}
.form-aside {
padding: 24px 28px;
}
.form-aside .form-section:last-child {
margin-bottom: 0;
} }
.page-header { .page-header {
@ -1119,7 +1142,21 @@ const saveAndCreateAnother = async () => {
} }
.form-body { .form-body {
padding: 24px 28px; display: flex;
flex-direction: column;
flex: 1;
padding: 0;
}
.form-body > .error-box,
.form-body > .success-box {
margin: 24px 28px 0;
}
.form-body > form {
display: flex;
flex-direction: column;
flex: 1;
} }
.section-heading { .section-heading {
@ -1127,7 +1164,9 @@ const saveAndCreateAnother = async () => {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
padding-bottom: 10px; margin-left: -28px;
margin-right: -28px;
padding: 0 28px 10px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
margin-bottom: 16px; margin-bottom: 16px;
} }
@ -1225,7 +1264,7 @@ const saveAndCreateAnother = async () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-top: 20px; padding: 20px 28px;
border-top: 1px dashed var(--border); border-top: 1px dashed var(--border);
} }
@ -1258,59 +1297,50 @@ const saveAndCreateAnother = async () => {
flex: 1; flex: 1;
} }
.agenda-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.agenda-row {
display: flex;
gap: 8px;
align-items: center;
}
.agenda-row .w-full {
flex: 1;
}
.link-btn {
background: none;
border: none;
color: var(--candle);
cursor: pointer;
font-family: 'Commit Mono', monospace;
font-size: 11px;
padding: 2px 6px;
}
.link-btn:hover {
text-decoration: underline;
}
.link-btn-danger {
color: var(--ember);
}
.add-agenda-btn {
align-self: flex-start;
color: var(--candle);
border-color: var(--candle);
border-style: dashed;
}
.btn:disabled, .btn:disabled,
.btn-primary:disabled { .btn-primary:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
:deep(.title-input) {
font-family: "Brygada 1918", serif;
font-size: 24px;
padding: 12px 14px;
}
@media (max-width: 1024px) {
.create-form::after {
display: none;
}
.form-layout {
grid-template-columns: 1fr;
}
.form-aside {
border-top: 1px dashed var(--border);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.page-header { .page-header {
padding: 24px 20px 16px; padding: 24px 20px 16px;
} }
.form-body { .form-main,
padding: 20px; .form-aside,
.form-actions {
padding-left: 20px;
padding-right: 20px;
}
.form-body > .error-box,
.form-body > .success-box {
margin-left: 20px;
margin-right: 20px;
}
.section-heading {
margin-left: -20px;
margin-right: -20px;
padding-left: 20px;
padding-right: 20px;
} }
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;