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:
@@ -0,0 +1,13 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 074_add_photo_cap');
|
||||
await addColumnIfNotExists(knex, 'events', 'photo_cap', (table) => {
|
||||
table.integer('photo_cap').nullable().defaultTo(null);
|
||||
});
|
||||
console.log('Migration 074_add_photo_cap completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 074_add_photo_cap');
|
||||
};
|
||||
@@ -42,7 +42,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
color_theme = null,
|
||||
expiration_days = 30,
|
||||
allow_user_uploads = false,
|
||||
upload_category_id = null
|
||||
upload_category_id = null,
|
||||
photo_cap = null
|
||||
} = req.body;
|
||||
|
||||
// Validate password strength for gallery
|
||||
@@ -105,7 +106,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
upload_category_id,
|
||||
photo_cap: photo_cap || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
|
||||
@@ -256,7 +256,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
header_style = 'standard',
|
||||
hero_divider_style = 'wave',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor = 'center'
|
||||
hero_image_anchor = 'center',
|
||||
// Photo cap
|
||||
photo_cap = null
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
@@ -416,7 +418,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
hero_logo_position: hero_logo_position || 'top',
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
hero_divider_style: effectiveDividerStyle || 'wave',
|
||||
hero_image_anchor: hero_image_anchor || 'center'
|
||||
hero_image_anchor: hero_image_anchor || 'center',
|
||||
photo_cap: photo_cap || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -479,6 +482,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
photo_cap: photo_cap || null,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString()
|
||||
|
||||
@@ -171,6 +171,29 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Enforce photo cap if set
|
||||
if (event.photo_cap && event.photo_cap > 0) {
|
||||
const existingPhotoCount = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.count('id as count')
|
||||
.first();
|
||||
const currentCount = parseInt(existingPhotoCount.count) || 0;
|
||||
const newFilesCount = (req.files && req.files.length) || 0;
|
||||
if (currentCount + newFilesCount > event.photo_cap) {
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: `Photo cap exceeded. This event allows a maximum of ${event.photo_cap} photos. Currently ${currentCount} photos exist, and you are trying to upload ${newFilesCount} more.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
|
||||
@@ -280,7 +280,8 @@ router.post('/gallery/verify', [
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
require_password: requiresPassword,
|
||||
photo_cap: event.photo_cap
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -353,7 +354,8 @@ router.post('/gallery/share-login', [
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
require_password: requiresPassword,
|
||||
photo_cap: event.photo_cap
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -138,7 +138,9 @@ const createEvent = async (eventData) => {
|
||||
show_feedback_to_guests,
|
||||
// Upload settings
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
upload_category_id,
|
||||
// Photo cap
|
||||
photo_cap
|
||||
} = eventData;
|
||||
|
||||
const requirePassword = parseBooleanInput(require_password, true);
|
||||
@@ -207,7 +209,9 @@ const createEvent = async (eventData) => {
|
||||
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
|
||||
// Upload settings
|
||||
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
|
||||
upload_category_id: upload_category_id || null
|
||||
upload_category_id: upload_category_id || null,
|
||||
// Photo cap
|
||||
photo_cap: photo_cap || null
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
|
||||
@@ -54,6 +54,8 @@ async function formatDate(date, language = 'en') {
|
||||
let locale = dateConfig.locale || 'en-GB';
|
||||
if (language === 'de') {
|
||||
locale = 'de-DE';
|
||||
} else if (language === 'pt') {
|
||||
locale = 'pt-BR';
|
||||
} else if (language === 'en' && dateConfig.format === 'MM/DD/YYYY') {
|
||||
locale = 'en-US';
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -455,6 +455,8 @@
|
||||
"expirationWarning": "Гости получат предупреждение по email за 7 дней до истечения.",
|
||||
"noExpiration": "Без срока действия",
|
||||
"noExpirationHelp": "Эта галерея будет активна до ручного архивирования.",
|
||||
"photoCap": "Лимит фото",
|
||||
"photoCapHelp": "Максимальное количество фотографий. 0 = без ограничений",
|
||||
"userUploads": "Настройки загрузки пользователями",
|
||||
"allowUserUploads": "Разрешить гостям загружать фото",
|
||||
"allowUserUploadsHelp": "Разрешить гостям загружать собственные фотографии в эту галерею",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user