Add human-readable event URL slugs with legacy redirect support.
Store unique slugs on events, backfill existing records, redirect old UUID and alias URLs to canonical slug pages, and expose slug editing plus alias management in the admin event modal. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -594,7 +594,7 @@ export default function AdminEventDetailPage() {
|
||||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
|
||||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
<Link href={`/events/${event.id}`} target="_blank">
|
||||
<Link href={`/events/${event.slug}`} target="_blank">
|
||||
<Button variant="outline" size="sm">
|
||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||
View Public
|
||||
@@ -618,7 +618,7 @@ export default function AdminEventDetailPage() {
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => { window.open(`/events/${event.id}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||||
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||||
<EyeIcon className="w-4 h-4 mr-2" /> View Public
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
||||
|
||||
@@ -28,9 +28,11 @@ export default function AdminEventsPage() {
|
||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
||||
|
||||
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
titleEs: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
descriptionEs: string;
|
||||
shortDescription: string;
|
||||
@@ -49,6 +51,7 @@ export default function AdminEventsPage() {
|
||||
}>({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
shortDescription: '',
|
||||
@@ -113,8 +116,9 @@ export default function AdminEventsPage() {
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSlugAliases([]);
|
||||
setFormData({
|
||||
title: '', titleEs: '', description: '', descriptionEs: '',
|
||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
|
||||
@@ -139,9 +143,30 @@ export default function AdminEventsPage() {
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
|
||||
};
|
||||
|
||||
const loadSlugAliases = async (eventId: string) => {
|
||||
try {
|
||||
const { aliases } = await eventsApi.getSlugAliases(eventId);
|
||||
setSlugAliases(aliases);
|
||||
} catch (error) {
|
||||
setSlugAliases([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAlias = async (slug: string) => {
|
||||
if (!editingEvent) return;
|
||||
if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return;
|
||||
try {
|
||||
await eventsApi.deleteSlugAlias(editingEvent.id, slug);
|
||||
toast.success('Alias removed');
|
||||
setSlugAliases((prev) => prev.filter((a) => a.slug !== slug));
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to remove alias');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (event: Event) => {
|
||||
setFormData({
|
||||
title: event.title, titleEs: event.titleEs || '',
|
||||
title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
|
||||
description: event.description, descriptionEs: event.descriptionEs || '',
|
||||
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||
@@ -154,6 +179,7 @@ export default function AdminEventsPage() {
|
||||
});
|
||||
setEditingEvent(event);
|
||||
setShowForm(true);
|
||||
loadSlugAliases(event.id);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -170,7 +196,7 @@ export default function AdminEventsPage() {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const eventData = {
|
||||
const eventData: Partial<Event> = {
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
@@ -183,6 +209,8 @@ export default function AdminEventsPage() {
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
};
|
||||
if (editingEvent) {
|
||||
// Only send slug when editing so creates still auto-generate from title
|
||||
eventData.slug = formData.slug || undefined;
|
||||
await eventsApi.update(editingEvent.id, eventData);
|
||||
toast.success('Event updated');
|
||||
} else {
|
||||
@@ -299,6 +327,38 @@ export default function AdminEventsPage() {
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{editingEvent && (
|
||||
<div>
|
||||
<Input label="URL Slug" value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="auto-generated from title" />
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
|
||||
. Changing the slug keeps the old one as a redirecting alias.
|
||||
</p>
|
||||
{slugAliases.length > 0 && (
|
||||
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
|
||||
<p className="text-sm font-medium mb-2">URL aliases</p>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Old URLs that still redirect to the current slug. Removing one breaks those links.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{slugAliases.map((alias) => (
|
||||
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-mono truncate">/events/{alias.slug}</span>
|
||||
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
|
||||
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
|
||||
title="Remove alias">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||
<textarea value={formData.description}
|
||||
|
||||
Reference in New Issue
Block a user