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>
|
||||
<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)"
|
||||
>
|
||||
→ {{ 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue