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.
238 lines
6.2 KiB
Vue
238 lines
6.2 KiB
Vue
<template>
|
|
<div class="natural-date-input">
|
|
<UInput
|
|
:model-value="rawInput"
|
|
:placeholder="placeholder"
|
|
:color="trailingState"
|
|
@update:model-value="onInputChange"
|
|
>
|
|
<template #trailing>
|
|
<Icon
|
|
v-if="isValid && rawInput.trim()"
|
|
name="heroicons:check-circle"
|
|
class="w-5 h-5"
|
|
style="color: var(--candle)"
|
|
/>
|
|
<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)"
|
|
>
|
|
→ {{ previewText }}
|
|
</p>
|
|
<p
|
|
v-else-if="rawInput.trim() && hasError"
|
|
class="preview-line"
|
|
style="color: var(--ember)"
|
|
>
|
|
{{ errorMessage }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import * as chrono from "chrono-node";
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: String, default: "" },
|
|
placeholder: {
|
|
type: String,
|
|
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 rawInput = ref("");
|
|
const isValid = ref(false);
|
|
const hasError = ref(false);
|
|
const errorMessage = ref("");
|
|
// 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);
|
|
|
|
const trailingState = computed(() => {
|
|
if (!rawInput.value.trim()) return undefined;
|
|
if (hasError.value) return "error";
|
|
if (isValid.value) return "success";
|
|
return undefined;
|
|
});
|
|
|
|
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(
|
|
() => 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>
|
|
.natural-date-input {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.preview-line {
|
|
font-size: 12px;
|
|
margin: 0;
|
|
}
|
|
</style>
|