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>
<div class="space-y-2">
<div v-if="!parsedDate || hasError || isEditing" class="relative">
<UInput
:model-value="naturalInput"
:placeholder="placeholder"
:color="
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="natural-date-input">
<UInput
:model-value="rawInput"
:placeholder="placeholder"
:color="trailingState"
@update:model-value="onInputChange"
>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon name="heroicons:calendar" class="w-4 h-4" />
<span>{{ formatParsedDate(parsedDate) }}</span>
</div>
<button
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"
<template #trailing>
<Icon
v-if="isValid && rawInput.trim()"
name="heroicons:check-circle"
class="w-5 h-5"
style="color: var(--candle)"
/>
</div>
</details>
<Icon
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>
</template>
@ -82,227 +42,197 @@
import * as chrono from "chrono-node";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
modelValue: { type: String, default: "" },
placeholder: {
type: String,
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"',
},
inputClass: {
type: [String, Object],
default: "",
},
required: {
type: Boolean,
default: false,
default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"',
},
displayTimezone: { type: String, default: "" },
required: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const naturalInput = ref("");
const parsedDate = ref(null);
const isValidParse = ref(false);
const rawInput = ref("");
const isValid = ref(false);
const hasError = ref(false);
const errorMessage = ref("");
const datetimeValue = ref("");
const isEditing = ref(false);
// previewDate holds the parsed value as a UTC Date so we can format it in
// arbitrary timezones without re-parsing. Source of truth for the preview.
const previewDate = ref(null);
// Initialize with current value
onMounted(() => {
if (props.modelValue) {
const date = new Date(props.modelValue);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
isValidParse.value = true;
}
}
const trailingState = computed(() => {
if (!rawInput.value.trim()) return undefined;
if (hasError.value) return "error";
if (isValid.value) return "success";
return undefined;
});
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(newValue) => {
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
const date = new Date(newValue);
if (!isNaN(date.getTime())) {
parsedDate.value = date;
datetimeValue.value = formatForDatetimeLocal(date);
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", {
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;
});
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,
});
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(
() => 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>
<style scoped>
.edit-link {
background: none;
border: none;
color: var(--candle);
cursor: pointer;
font-family: "Commit Mono", monospace;
font-size: 11px;
padding: 0;
text-decoration: underline;
.natural-date-input {
display: flex;
flex-direction: column;
gap: 6px;
}
.edit-link:hover {
color: var(--ember);
.preview-line {
font-size: 12px;
margin: 0;
}
</style>