- 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user