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) => {
if (event.is_archived) return { label: t('events.archived'), color: 'text-neutral-500 bg-neutral-100' };
if (!event.is_active) return { label: t('events.inactive'), color: 'text-red-600 bg-red-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 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;
if (days <= 0) return { label: t('events.expired'), color: 'text-red-600 bg-red-100' };
if (days <= 7) return { label: t('events.daysLeft', { count: days }), color: 'text-orange-600 bg-orange-100' };
return { label: t('events.active'), color: 'text-green-600 bg-green-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 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/40' };
return { label: t('events.active'), color: 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/40' };
};
if (isLoading) {
@@ -205,8 +205,8 @@ export const EventsListPage: React.FC = () => {
<div>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.title')}</h1>
<p className="text-neutral-600 mt-1">{t('events.subtitle')}</p>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('events.title')}</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('events.subtitle')}</p>
</div>
</div>
<SkeletonTable rows={5} />
@@ -231,8 +231,8 @@ export const EventsListPage: React.FC = () => {
{/* Page Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.title')}</h1>
<p className="text-neutral-600 mt-1">{t('events.subtitle')}</p>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('events.title')}</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('events.subtitle')}</p>
</div>
<Button
variant="primary"
@@ -248,8 +248,8 @@ export const EventsListPage: React.FC = () => {
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.totalEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">{data?.events.length || 0}</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 dark:text-neutral-100">{data?.events.length || 0}</p>
</div>
<Calendar className="w-8 h-8 text-primary-600" />
</div>
@@ -258,8 +258,8 @@ export const EventsListPage: React.FC = () => {
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.activeEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">
<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 dark:text-neutral-100">
{data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
</p>
</div>
@@ -270,8 +270,8 @@ export const EventsListPage: React.FC = () => {
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.totalPhotos')}</p>
<p className="text-2xl font-bold text-neutral-900">
<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 dark:text-neutral-100">
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
</p>
</div>
@@ -282,8 +282,8 @@ export const EventsListPage: React.FC = () => {
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.expiringEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">
<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 dark:text-neutral-100">
{data?.events.filter(e => {
if (!e.is_active || e.is_archived) return false;
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
@@ -350,8 +350,8 @@ export const EventsListPage: React.FC = () => {
{/* Bulk Actions */}
{selectedEvents.length > 0 && (
<div className="mt-4 p-3 bg-primary-50 rounded-lg flex items-center justify-between">
<span className="text-sm text-primary-900">
<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 dark:text-primary-100">
{t('events.eventsSelected', { count: selectedEvents.length })}
</span>
<div className="flex gap-2">
@@ -374,67 +374,71 @@ export const EventsListPage: React.FC = () => {
<Card className="overflow-visible">
<div className="overflow-x-auto overflow-y-visible">
<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>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={selectedEvents.length === filteredEvents.length && filteredEvents.length > 0}
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 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')}
</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')}
</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')}
</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')}
</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')}
</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')}
</th>
</tr>
</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 ? (
<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')}
</td>
</tr>
) : (
filteredEvents.map((event) => {
const status = getEventStatus(event);
return (
<tr key={event.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<tr
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
type="checkbox"
checked={selectedEvents.includes(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 className="px-6 py-4">
<div>
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
<p className="text-xs text-neutral-500">{event.customer_email}</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 dark:text-neutral-400">{event.customer_email}</p>
<div className="mt-1">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
isGalleryPublic(event.require_password)
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-700'
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
: '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')}
@@ -442,10 +446,10 @@ export const EventsListPage: React.FC = () => {
</div>
</div>
</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}
</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'}
</td>
<td className="px-6 py-4">
@@ -453,105 +457,131 @@ export const EventsListPage: React.FC = () => {
{status.label}
</span>
</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'}
</td>
<td className="px-6 py-4 text-right">
<div className="relative inline-block text-left dropdown-container">
<button
onClick={(e) => {
e.stopPropagation();
if (activeDropdown === event.id) {
setActiveDropdown(null);
setDropdownPosition(null);
} else {
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` }}
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1">
{/* Inline action buttons - hidden on mobile */}
<div className="hidden md:flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/admin/events/${event.id}`)}
title={t('events.viewDetails')}
>
<div className="py-1">
<button
onClick={() => {
navigate(`/admin/events/${event.id}`);
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"
>
<Edit className="w-4 h-4" />
{t('events.viewDetails')}
</button>
{event.share_link ? (
<a
href={resolveShareLink(event.share_link)}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => {
setActiveDropdown(null);
setDropdownPosition(null);
}}
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
</a>
) : null}
{!event.is_archived ? (
<Edit className="w-4 h-4" />
</Button>
{event.share_link && (
<Button
variant="ghost"
size="sm"
onClick={() => window.open(resolveShareLink(event.share_link), '_blank')}
title={t('events.viewGallery')}
>
<ExternalLink className="w-4 h-4" />
</Button>
)}
</div>
{/* Context menu for additional actions */}
<div className="relative inline-block text-left dropdown-container">
<button
onClick={(e) => {
e.stopPropagation();
if (activeDropdown === event.id) {
setActiveDropdown(null);
setDropdownPosition(null);
} else {
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 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
onClick={() => {
archiveMutation.mutate(event.id);
navigate(`/admin/events/${event.id}`);
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="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" />
{t('events.archiveEventAction')}
<Edit className="w-4 h-4" />
{t('events.viewDetails')}
</button>
) : null}
{event.is_archived ? (
{event.share_link ? (
<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
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
setActiveDropdown(null);
setDropdownPosition(null);
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-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" />
{t('events.downloadArchiveAction')}
<Trash2 className="w-4 h-4" />
{t('events.deleteEvent')}
</button>
) : null}
<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>
</td>
</tr>
@@ -192,9 +192,10 @@ export const AcceptInvitePage: React.FC = () => {
if (!formData.password) {
newErrors.password = t('acceptInvitation.errors.passwordRequired');
} else {
const allRequirementsMet = passwordRequirements.every(req => req.test(formData.password));
if (!allRequirementsMet) {
newErrors.password = t('acceptInvitation.errors.passwordTooShort');
// Find the first failing requirement and show its specific error
const failingRequirement = passwordRequirements.find(req => !req.test(formData.password));
if (failingRequirement) {
newErrors.password = failingRequirement.label;
}
}