fix: improve password validation errors and event list UX (#170, #171)

- Show specific failing password requirement instead of generic error
  when password validation fails on AcceptInvitePage (#170)
- Add inline Edit and View Gallery buttons to events table (#171)
- Make event table rows clickable to navigate to details (#171)
- Keep context menu for less common actions (Archive, Delete)
- Add responsive design: inline buttons hidden on mobile
This commit is contained in:
Paul Nothaft
2026-02-06 18:38:12 +01:00
parent e179def3cc
commit 171abb3161
2 changed files with 158 additions and 127 deletions
+154 -124
View File
@@ -190,14 +190,14 @@ export const EventsListPage: React.FC = () => {
}; };
const getEventStatus = (event: Event) => { const getEventStatus = (event: Event) => {
if (event.is_archived) return { label: t('events.archived'), color: 'text-neutral-500 bg-neutral-100' }; if (event.is_archived) return { label: t('events.archived'), color: 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-700' };
if (!event.is_active) return { label: t('events.inactive'), color: 'text-red-600 bg-red-100' }; if (!event.is_active) return { label: t('events.inactive'), color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/40' };
const days = event.expires_at ? differenceInDays(parseISO(event.expires_at), new Date()) : 0; const days = event.expires_at ? differenceInDays(parseISO(event.expires_at), new Date()) : 0;
if (days <= 0) return { label: t('events.expired'), color: 'text-red-600 bg-red-100' }; if (days <= 0) return { label: t('events.expired'), color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/40' };
if (days <= 7) return { label: t('events.daysLeft', { count: days }), color: 'text-orange-600 bg-orange-100' }; if (days <= 7) return { label: t('events.daysLeft', { count: days }), color: 'text-orange-600 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/40' };
return { label: t('events.active'), color: 'text-green-600 bg-green-100' }; return { label: t('events.active'), color: 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/40' };
}; };
if (isLoading) { if (isLoading) {
@@ -205,8 +205,8 @@ export const EventsListPage: React.FC = () => {
<div> <div>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.title')}</h1> <h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('events.title')}</h1>
<p className="text-neutral-600 mt-1">{t('events.subtitle')}</p> <p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('events.subtitle')}</p>
</div> </div>
</div> </div>
<SkeletonTable rows={5} /> <SkeletonTable rows={5} />
@@ -231,8 +231,8 @@ export const EventsListPage: React.FC = () => {
{/* Page Header */} {/* Page Header */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.title')}</h1> <h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('events.title')}</h1>
<p className="text-neutral-600 mt-1">{t('events.subtitle')}</p> <p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('events.subtitle')}</p>
</div> </div>
<Button <Button
variant="primary" variant="primary"
@@ -248,8 +248,8 @@ export const EventsListPage: React.FC = () => {
<Card padding="sm"> <Card padding="sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-neutral-600">{t('events.stats.totalEvents')}</p> <p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">{data?.events.length || 0}</p> <p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{data?.events.length || 0}</p>
</div> </div>
<Calendar className="w-8 h-8 text-primary-600" /> <Calendar className="w-8 h-8 text-primary-600" />
</div> </div>
@@ -258,8 +258,8 @@ export const EventsListPage: React.FC = () => {
<Card padding="sm"> <Card padding="sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-neutral-600">{t('events.stats.activeEvents')}</p> <p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.activeEvents')}</p>
<p className="text-2xl font-bold text-neutral-900"> <p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{data?.events.filter(e => e.is_active && !e.is_archived).length || 0} {data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
</p> </p>
</div> </div>
@@ -270,8 +270,8 @@ export const EventsListPage: React.FC = () => {
<Card padding="sm"> <Card padding="sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-neutral-600">{t('events.stats.totalPhotos')}</p> <p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalPhotos')}</p>
<p className="text-2xl font-bold text-neutral-900"> <p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0} {data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
</p> </p>
</div> </div>
@@ -282,8 +282,8 @@ export const EventsListPage: React.FC = () => {
<Card padding="sm"> <Card padding="sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-neutral-600">{t('events.stats.expiringEvents')}</p> <p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.expiringEvents')}</p>
<p className="text-2xl font-bold text-neutral-900"> <p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{data?.events.filter(e => { {data?.events.filter(e => {
if (!e.is_active || e.is_archived) return false; if (!e.is_active || e.is_archived) return false;
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0; const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
@@ -350,8 +350,8 @@ export const EventsListPage: React.FC = () => {
{/* Bulk Actions */} {/* Bulk Actions */}
{selectedEvents.length > 0 && ( {selectedEvents.length > 0 && (
<div className="mt-4 p-3 bg-primary-50 rounded-lg flex items-center justify-between"> <div className="mt-4 p-3 bg-primary-50 dark:bg-primary-900/30 rounded-lg flex items-center justify-between">
<span className="text-sm text-primary-900"> <span className="text-sm text-primary-900 dark:text-primary-100">
{t('events.eventsSelected', { count: selectedEvents.length })} {t('events.eventsSelected', { count: selectedEvents.length })}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -374,67 +374,71 @@ export const EventsListPage: React.FC = () => {
<Card className="overflow-visible"> <Card className="overflow-visible">
<div className="overflow-x-auto overflow-y-visible"> <div className="overflow-x-auto overflow-y-visible">
<table className="w-full"> <table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200"> <thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<tr> <tr>
<th className="px-6 py-3 text-left"> <th className="px-6 py-3 text-left">
<input <input
type="checkbox" type="checkbox"
checked={selectedEvents.length === filteredEvents.length && filteredEvents.length > 0} checked={selectedEvents.length === filteredEvents.length && filteredEvents.length > 0}
onChange={handleSelectAll} onChange={handleSelectAll}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700"
/> />
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('events.event')} {t('events.event')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('events.type')} {t('events.type')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('events.date')} {t('events.date')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('events.status')} {t('events.status')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('events.expires')} {t('events.expires')}
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('events.actions')} {t('events.actions')}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-neutral-200"> <tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{filteredEvents.length === 0 ? ( {filteredEvents.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-6 py-12 text-center text-neutral-500"> <td colSpan={7} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
{t('events.noEventsFound')} {t('events.noEventsFound')}
</td> </td>
</tr> </tr>
) : ( ) : (
filteredEvents.map((event) => { filteredEvents.map((event) => {
const status = getEventStatus(event); const status = getEventStatus(event);
return ( return (
<tr key={event.id} className="hover:bg-neutral-50"> <tr
<td className="px-6 py-4"> key={event.id}
className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50 cursor-pointer"
onClick={() => navigate(`/admin/events/${event.id}`)}
>
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
<input <input
type="checkbox" type="checkbox"
checked={selectedEvents.includes(event.id)} checked={selectedEvents.includes(event.id)}
onChange={() => handleSelectEvent(event.id)} onChange={() => handleSelectEvent(event.id)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700"
/> />
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div> <div>
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p> <p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.event_name}</p>
<p className="text-xs text-neutral-500">{event.customer_email}</p> <p className="text-xs text-neutral-500 dark:text-neutral-400">{event.customer_email}</p>
<div className="mt-1"> <div className="mt-1">
<span <span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
isGalleryPublic(event.require_password) isGalleryPublic(event.require_password)
? 'bg-green-100 text-green-700' ? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
: 'bg-neutral-100 text-neutral-700' : 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
}`} }`}
> >
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')} {isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
@@ -442,10 +446,10 @@ export const EventsListPage: React.FC = () => {
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-sm text-neutral-700"> <td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300">
{event.event_type} {event.event_type}
</td> </td>
<td className="px-6 py-4 text-sm text-neutral-700"> <td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300">
{event.event_date ? format(parseISO(event.event_date), 'MMM d, yyyy') : 'N/A'} {event.event_date ? format(parseISO(event.event_date), 'MMM d, yyyy') : 'N/A'}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
@@ -453,105 +457,131 @@ export const EventsListPage: React.FC = () => {
{status.label} {status.label}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-sm text-neutral-700"> <td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300">
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'} {event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
<div className="relative inline-block text-left dropdown-container"> <div className="flex items-center gap-1">
<button {/* Inline action buttons - hidden on mobile */}
onClick={(e) => { <div className="hidden md:flex items-center gap-1">
e.stopPropagation(); <Button
if (activeDropdown === event.id) { variant="ghost"
setActiveDropdown(null); size="sm"
setDropdownPosition(null); onClick={() => navigate(`/admin/events/${event.id}`)}
} else { title={t('events.viewDetails')}
const rect = e.currentTarget.getBoundingClientRect();
setActiveDropdown(event.id);
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
});
}
}}
className="text-neutral-400 hover:text-neutral-600 p-1"
>
<MoreVertical className="w-5 h-5" />
</button>
{activeDropdown === event.id && dropdownPosition && (
<div
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
> >
<div className="py-1"> <Edit className="w-4 h-4" />
<button </Button>
onClick={() => { {event.share_link && (
navigate(`/admin/events/${event.id}`); <Button
setActiveDropdown(null); variant="ghost"
setDropdownPosition(null); size="sm"
}} onClick={() => window.open(resolveShareLink(event.share_link), '_blank')}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2" title={t('events.viewGallery')}
> >
<Edit className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
{t('events.viewDetails')} </Button>
</button> )}
{event.share_link ? ( </div>
<a
href={resolveShareLink(event.share_link)} {/* Context menu for additional actions */}
target="_blank" <div className="relative inline-block text-left dropdown-container">
rel="noopener noreferrer" <button
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2" onClick={(e) => {
onClick={() => { e.stopPropagation();
setActiveDropdown(null); if (activeDropdown === event.id) {
setDropdownPosition(null); setActiveDropdown(null);
}} setDropdownPosition(null);
> } else {
<ExternalLink className="w-4 h-4" /> const rect = e.currentTarget.getBoundingClientRect();
{t('events.viewGallery')} setActiveDropdown(event.id);
</a> setDropdownPosition({
) : null} top: rect.bottom + window.scrollY,
{!event.is_archived ? ( left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
});
}
}}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 p-1"
>
<MoreVertical className="w-5 h-5" />
</button>
{activeDropdown === event.id && dropdownPosition && (
<div
className="fixed z-50 w-56 rounded-md shadow-lg bg-white dark:bg-neutral-800 ring-1 ring-black ring-opacity-5 dark:ring-neutral-700"
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
>
<div className="py-1">
{/* Show Edit/View on mobile only (already visible inline on desktop) */}
<button <button
onClick={() => { onClick={() => {
archiveMutation.mutate(event.id); navigate(`/admin/events/${event.id}`);
setActiveDropdown(null); setActiveDropdown(null);
setDropdownPosition(null); setDropdownPosition(null);
}} }}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2" className="md:hidden w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
> >
<Archive className="w-4 h-4" /> <Edit className="w-4 h-4" />
{t('events.archiveEventAction')} {t('events.viewDetails')}
</button> </button>
) : null} {event.share_link ? (
{event.is_archived ? ( <a
href={resolveShareLink(event.share_link)}
target="_blank"
rel="noopener noreferrer"
className="md:hidden w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
onClick={() => {
setActiveDropdown(null);
setDropdownPosition(null);
}}
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
</a>
) : null}
{!event.is_archived ? (
<button
onClick={() => {
archiveMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
>
<Archive className="w-4 h-4" />
{t('events.archiveEventAction')}
</button>
) : null}
{event.is_archived ? (
<button
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('events.downloadArchiveAction')}
</button>
) : null}
<button <button
onClick={() => { onClick={() => {
toast.info(t('events.downloadArchiveSoon')); if (confirm(t('events.deleteEventConfirm'))) {
setActiveDropdown(null); deleteMutation.mutate(event.id);
setDropdownPosition(null); setActiveDropdown(null);
setDropdownPosition(null);
}
}} }}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2" className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 flex items-center gap-2"
> >
<Download className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
{t('events.downloadArchiveAction')} {t('events.deleteEvent')}
</button> </button>
) : null} </div>
<button
onClick={() => {
if (confirm(t('events.deleteEventConfirm'))) {
deleteMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{t('events.deleteEvent')}
</button>
</div> </div>
</div> )}
)} </div>
</div> </div>
</td> </td>
</tr> </tr>
@@ -192,9 +192,10 @@ export const AcceptInvitePage: React.FC = () => {
if (!formData.password) { if (!formData.password) {
newErrors.password = t('acceptInvitation.errors.passwordRequired'); newErrors.password = t('acceptInvitation.errors.passwordRequired');
} else { } else {
const allRequirementsMet = passwordRequirements.every(req => req.test(formData.password)); // Find the first failing requirement and show its specific error
if (!allRequirementsMet) { const failingRequirement = passwordRequirements.find(req => !req.test(formData.password));
newErrors.password = t('acceptInvitation.errors.passwordTooShort'); if (failingRequirement) {
newErrors.password = failingRequirement.label;
} }
} }