feat: add photo cap per event and Portuguese (pt-BR) locale

- Add photo_cap column to events table (migration 074) to limit photos per event
- Enforce photo cap in upload route, returning 400 when limit exceeded
- Pass photo_cap through all event CRUD routes and frontend forms
- Add complete Portuguese (pt-BR) translation (2300+ strings)
- Register pt locale in i18n config, language selector, date formatting
- Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt)
This commit is contained in:
Paul Nothaft
2026-03-16 17:22:54 +01:00
parent 6aceb40595
commit 1fa222e9c4
19 changed files with 2451 additions and 12 deletions
@@ -29,10 +29,20 @@ const RUFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
</svg>
);
const PTBRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#009B3A" d="M0 0h640v480H0z"/>
<path fill="#FEDF00" d="M320 39.4 590.4 240 320 440.6 49.6 240z"/>
<circle fill="#002776" cx="320" cy="240" r="95"/>
<path fill="#FFF" d="M226.3 262.8c0-27 12.8-51 32.7-66.3a95.3 95.3 0 0 0-3.5 120.6c-17.8-14.8-29.2-37-29.2-54.3z" opacity=".5"/>
</svg>
);
const languages = [
{ code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
{ code: 'ru', name: 'Русский', Flag: RUFlag },
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
];
export const LanguageSelector: React.FC = () => {
@@ -246,6 +246,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="pt">Português (Brasil)</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.defaultLanguageHelp')}
+4 -2
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { de, enUS, ptBR } from 'date-fns/locale';
import { useQuery } from '@tanstack/react-query';
import { publicSettingsService } from '../services/publicSettings.service';
@@ -24,7 +24,9 @@ export const useLocalizedDate = () => {
});
const getLocale = () => {
return i18n.language === 'de' ? de : enUS;
if (i18n.language === 'de') return de;
if (i18n.language === 'pt' || i18n.language === 'pt-BR') return ptBR;
return enUS;
};
const format = (date: Date | string, formatStr?: string) => {
+4
View File
@@ -6,6 +6,7 @@ import HttpBackend from 'i18next-http-backend';
import enTranslations from './locales/en.json';
import deTranslations from './locales/de.json';
import ruTranslations from './locales/ru.json';
import ptTranslations from './locales/pt.json';
i18n
.use(HttpBackend)
@@ -25,6 +26,9 @@ i18n
ru: {
translation: ruTranslations,
},
pt: {
translation: ptTranslations,
},
},
interpolation: {
+2
View File
@@ -835,6 +835,8 @@
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"noExpiration": "Kein Ablaufdatum",
"noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.",
"photoCap": "Fotolimit",
"photoCapHelp": "Maximale Anzahl erlaubter Fotos. 0 = unbegrenzt",
"userUploads": "Benutzer-Upload-Einstellungen",
"allowUserUploads": "Gästen erlauben, Fotos hochzuladen",
"allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
+2
View File
@@ -461,6 +461,8 @@
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
"noExpiration": "No Expiration",
"noExpirationHelp": "This gallery will remain active until manually archived.",
"photoCap": "Photo Limit",
"photoCapHelp": "Maximum number of photos allowed. 0 = unlimited",
"userUploads": "User Upload Settings",
"allowUserUploads": "Allow guests to upload photos",
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
File diff suppressed because it is too large Load Diff
+2
View File
@@ -455,6 +455,8 @@
"expirationWarning": "Гости получат предупреждение по email за 7 дней до истечения.",
"noExpiration": "Без срока действия",
"noExpirationHelp": "Эта галерея будет активна до ручного архивирования.",
"photoCap": "Лимит фото",
"photoCapHelp": "Максимальное количество фотографий. 0 = без ограничений",
"userUploads": "Настройки загрузки пользователями",
"allowUserUploads": "Разрешить гостям загружать фото",
"allowUserUploadsHelp": "Разрешить гостям загружать собственные фотографии в эту галерею",
+26 -1
View File
@@ -8,7 +8,8 @@ import {
ArrowLeft,
Palette,
Eye,
EyeOff
EyeOff,
Image
} from 'lucide-react';
import { addDays } from 'date-fns';
import { toast } from 'react-toastify';
@@ -44,6 +45,7 @@ interface FormData {
allow_user_uploads: boolean;
upload_category_id: number | null;
css_template_id: number | null;
photo_cap: number;
feedback_settings: {
feedback_enabled: boolean;
allow_ratings: boolean;
@@ -98,6 +100,7 @@ export const CreateEventPage: React.FC = () => {
allow_user_uploads: false,
upload_category_id: null,
css_template_id: null,
photo_cap: 0,
feedback_settings: {
feedback_enabled: false,
allow_ratings: true,
@@ -298,6 +301,7 @@ export const CreateEventPage: React.FC = () => {
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
css_template_id: formData.css_template_id,
photo_cap: formData.photo_cap > 0 ? formData.photo_cap : null,
feedback_enabled: feedbackSettings.feedback_enabled,
allow_ratings: feedbackSettings.allow_ratings,
allow_likes: feedbackSettings.allow_likes,
@@ -717,6 +721,27 @@ export const CreateEventPage: React.FC = () => {
</div>
)}
{/* Photo Cap */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('events.photoCap', 'Photo Limit')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.photo_cap}
onChange={(e) => setFormData({ ...formData, photo_cap: parseInt(e.target.value) || 0 })}
min={0}
leftIcon={<Image className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')}
</span>
</div>
</div>
{/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="flex items-center gap-3">
@@ -172,6 +172,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_position: 'top' | 'center' | 'bottom';
// Hero image anchor position (#162) keyword or "X% Y%" focal point
hero_image_anchor: string;
// Photo cap
photo_cap: number;
};
const [isEditing, setIsEditing] = useState(false);
@@ -202,6 +204,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_position: 'top',
// Hero image anchor position (#162)
hero_image_anchor: 'center',
// Photo cap
photo_cap: 0,
});
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false,
@@ -417,6 +421,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_position: event.hero_logo_position || 'top',
// Hero image anchor position (#162)
hero_image_anchor: event.hero_image_anchor || 'center',
// Photo cap
photo_cap: event.photo_cap || 0,
});
setShowNewPassword(false);
@@ -550,6 +556,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_position: editForm.hero_logo_position,
// Hero image anchor position (#162)
hero_image_anchor: editForm.hero_image_anchor,
// Photo cap
photo_cap: editForm.photo_cap > 0 ? editForm.photo_cap : null,
// Header style settings (decoupled from layout, #158)
header_style: currentTheme?.headerStyle || 'standard',
hero_divider_style: currentTheme?.heroDividerStyle || 'wave',
@@ -1026,6 +1034,25 @@ export const EventDetailsPage: React.FC = () => {
</div>
)}
{/* Photo Cap */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.photoCap', 'Photo Limit')}
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={editForm.photo_cap}
onChange={(e) => setEditForm(prev => ({ ...prev, photo_cap: parseInt(e.target.value) || 0 }))}
min={0}
className="w-24 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')}
</span>
</div>
</div>
<div>
<label className="flex items-center">
<input
+2
View File
@@ -39,6 +39,7 @@ interface CreateEventData {
require_name_email?: boolean;
moderate_comments?: boolean;
show_feedback_to_guests?: boolean;
photo_cap?: number | null;
}
interface UpdateEventData {
@@ -58,6 +59,7 @@ interface UpdateEventData {
hero_photo_id?: number | null;
source_mode?: 'managed' | 'reference';
external_path?: string | null;
photo_cap?: number | null;
}
interface EventsListResponse {
+3
View File
@@ -53,6 +53,8 @@ export interface Event {
hero_image_anchor?: string;
// CSS Template
css_template_id?: number | null;
// Photo cap
photo_cap?: number | null;
}
export interface GalleryInfo {
@@ -202,6 +204,7 @@ export interface GalleryAuthResponse {
allow_user_uploads?: boolean;
upload_category_id?: number | null;
require_password?: boolean;
photo_cap?: number | null;
};
}