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:
parent
622cc8e53b
commit
4a05e91715
2 changed files with 474 additions and 514 deletions
|
|
@ -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="naturalInput"
|
:model-value="rawInput"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:color="
|
:color="trailingState"
|
||||||
hasError && naturalInput.trim()
|
@update:model-value="onInputChange"
|
||||||
? 'error'
|
|
||||||
: isValidParse && naturalInput.trim()
|
|
||||||
? 'success'
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
@update:model-value="onNaturalInputChange"
|
|
||||||
@blur="onBlur"
|
|
||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="isValidParse && naturalInput.trim()"
|
v-if="isValid && rawInput.trim()"
|
||||||
name="heroicons:check-circle"
|
name="heroicons:check-circle"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
style="color: var(--candle)"
|
style="color: var(--candle)"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
v-else-if="hasError && naturalInput.trim()"
|
v-else-if="hasError && rawInput.trim()"
|
||||||
name="heroicons:exclamation-circle"
|
name="heroicons:exclamation-circle"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
style="color: var(--ember)"
|
style="color: var(--ember)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
</div>
|
<p
|
||||||
|
v-if="rawInput.trim() && isValid"
|
||||||
<div
|
class="preview-line"
|
||||||
v-if="parsedDate && isValidParse && !isEditing"
|
style="color: var(--candle)"
|
||||||
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">
|
→ {{ previewText }}
|
||||||
<div class="flex items-center gap-2">
|
</p>
|
||||||
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
<p
|
||||||
<span>{{ formatParsedDate(parsedDate) }}</span>
|
v-else-if="rawInput.trim() && hasError"
|
||||||
</div>
|
class="preview-line"
|
||||||
<button
|
style="color: var(--ember)"
|
||||||
type="button"
|
|
||||||
class="edit-link"
|
|
||||||
@click="clearAndEdit"
|
|
||||||
>
|
>
|
||||||
Edit
|
{{ errorMessage }}
|
||||||
</button>
|
</p>
|
||||||
</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>
|
|
||||||
</details>
|
|
||||||
</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(() => {
|
||||||
|
if (!previewDate.value) return "";
|
||||||
|
const tz = activeTZ();
|
||||||
|
const date = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: tz,
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
}).format(previewDate.value);
|
||||||
|
const abbr = shortTimezoneName(previewDate.value, tz);
|
||||||
|
return abbr ? `${date} ${abbr}` : date;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTZ = () =>
|
||||||
|
props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// Seed the input from modelValue without triggering chrono. The parent's
|
||||||
|
// value is canonical — we just render it as a chrono-friendly readable
|
||||||
|
// string so the user can backspace and tweak in place.
|
||||||
|
const seedFromModelValue = () => {
|
||||||
|
if (!props.modelValue) {
|
||||||
|
rawInput.value = "";
|
||||||
|
isValid.value = false;
|
||||||
|
hasError.value = false;
|
||||||
|
errorMessage.value = "";
|
||||||
|
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(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newValue) => {
|
(next) => {
|
||||||
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
|
const tz = activeTZ();
|
||||||
const date = new Date(newValue);
|
const expected = previewDate.value
|
||||||
if (!isNaN(date.getTime())) {
|
? utcToZonedLocal(previewDate.value, tz)
|
||||||
parsedDate.value = date;
|
: "";
|
||||||
datetimeValue.value = formatForDatetimeLocal(date);
|
if (next === expected) return;
|
||||||
isValidParse.value = true;
|
seedFromModelValue();
|
||||||
naturalInput.value = ""; // Clear natural input when set externally
|
|
||||||
isEditing.value = false;
|
|
||||||
}
|
|
||||||
} else if (!newValue) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const parseNaturalInput = () => {
|
watch(
|
||||||
const input = naturalInput.value.trim();
|
() => 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!input) {
|
const onInputChange = (value) => {
|
||||||
reset();
|
rawInput.value = value;
|
||||||
return;
|
parse(value);
|
||||||
}
|
|
||||||
|
|
||||||
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
|
const parse = (input) => {
|
||||||
// template merges $attrs with an explicit @input and can drop keystrokes).
|
const trimmed = input.trim();
|
||||||
// update:model-value is a declared emit and fires for every change.
|
if (!trimmed) {
|
||||||
const onNaturalInputChange = (value) => {
|
isValid.value = false;
|
||||||
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;
|
hasError.value = false;
|
||||||
errorMessage.value = "";
|
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", "");
|
emit("update:modelValue", "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const setError = (message) => {
|
// Build a Date object whose browser-local components equal the current
|
||||||
isValidParse.value = false;
|
// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next
|
||||||
hasError.value = true;
|
// Friday" anchor to the event TZ rather than the editor's browser TZ.
|
||||||
errorMessage.value = message;
|
const referenceNowInTZ = (tz) => {
|
||||||
parsedDate.value = null;
|
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 clearAndEdit = () => {
|
const browserComponentsToString = (date) => {
|
||||||
// Pre-fill the input with a readable string so the user doesn't start over.
|
const y = date.getFullYear();
|
||||||
naturalInput.value = parsedDate.value
|
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
? parsedDate.value.toLocaleString("en-US", {
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
month: "long",
|
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",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: true,
|
hour12: true,
|
||||||
})
|
}).format(utc);
|
||||||
: "";
|
|
||||||
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",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isToday) {
|
|
||||||
return `Today at ${timeStr}`;
|
|
||||||
} else if (isTomorrow) {
|
|
||||||
return `Tomorrow at ${timeStr}`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleString("en-US", {
|
|
||||||
weekday: "long",
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 Main talk - 30 mins Q&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"
|
||||||
|
@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"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
Tag this event to help with discovery and recommendations
|
Pool assigned to any new tag you create from this field.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="help-text">
|
||||||
|
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue