Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 617f292516 | |||
| 8e95004022 |
@@ -39,6 +39,12 @@ docker-compose -f docker-compose.prod.yml up -d # Production deployment
|
||||
pm2 start ecosystem.config.js # Alternative: PM2 deployment
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL PRODUCTION NOTICE:**
|
||||
- Production runs on a SEPARATE SERVER - never assume local changes affect production
|
||||
- ALWAYS request production server details before any troubleshooting
|
||||
- NO trial-and-error approaches in production - data loss is unacceptable
|
||||
- Every change must be thoroughly analyzed and tested locally first
|
||||
|
||||
## Key Product Requirements (from PRD)
|
||||
|
||||
### Core Features
|
||||
@@ -127,6 +133,41 @@ Background services run as separate processes:
|
||||
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
||||
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
||||
|
||||
## Troubleshooting Guidelines
|
||||
|
||||
### Before ANY Production Troubleshooting:
|
||||
1. **ALWAYS request specific details**:
|
||||
- Production server URL/IP
|
||||
- Current error messages/logs
|
||||
- Recent changes or deployments
|
||||
- Affected users/galleries
|
||||
- Time of issue occurrence
|
||||
|
||||
2. **Thorough Analysis Required**:
|
||||
- Use detailed thinking/analysis for EVERY troubleshooting task
|
||||
- Review all related code before suggesting changes
|
||||
- Consider all potential side effects
|
||||
- Never make assumptions about production environment
|
||||
|
||||
3. **Safe Troubleshooting Steps**:
|
||||
- First, reproduce issue in local/dev environment
|
||||
- Analyze logs without modifying production
|
||||
- Create detailed action plan before any changes
|
||||
- Always have rollback strategy ready
|
||||
- Document every step taken
|
||||
|
||||
### Common Issues & Safe Approaches:
|
||||
- **Email not sending**: Check email_queue table, SMTP settings, service status
|
||||
- **Photos not loading**: Verify file permissions, storage paths, nginx config
|
||||
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
|
||||
- **Performance problems**: Analyze with monitoring tools first, never experiment
|
||||
|
||||
### Data Safety Rules:
|
||||
- NEVER delete or modify production data without explicit backup confirmation
|
||||
- ALWAYS verify backups exist before any data operations
|
||||
- NO direct database modifications without transaction safety
|
||||
- Log all actions for audit trail
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
@@ -258,4 +299,30 @@ const { theme, setTheme, setThemeByName } = useTheme();
|
||||
- Guest satisfaction: >90%
|
||||
- System uptime: 99.9%
|
||||
- Email delivery rate: >98%
|
||||
- Successful archiving: 100%
|
||||
- Successful archiving: 100%
|
||||
|
||||
## Documentation & Development Practices
|
||||
|
||||
### Documentation Guidelines:
|
||||
- **NEVER create new documentation files for simple tasks**
|
||||
- **ALWAYS update existing documentation (like this CLAUDE.md)**
|
||||
- Only create new .md files when explicitly requested
|
||||
- Avoid creating temporary scripts for one-off tasks
|
||||
|
||||
### Development Best Practices:
|
||||
- Test all changes thoroughly in local environment first
|
||||
- Use version control for all changes
|
||||
- Keep commits atomic and well-described
|
||||
- Review impact on all integrated services
|
||||
- Consider backward compatibility
|
||||
- Update tests when changing functionality
|
||||
|
||||
### Production Deployment Checklist:
|
||||
- [ ] All tests passing locally
|
||||
- [ ] Linting and type checks pass
|
||||
- [ ] Database migrations tested with rollback plan
|
||||
- [ ] Environment variables documented
|
||||
- [ ] Backup strategy confirmed
|
||||
- [ ] Monitoring alerts configured
|
||||
- [ ] Rollback procedure documented
|
||||
- [ ] Stakeholders notified of maintenance window
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.67",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.67",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -48,9 +48,9 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// Get total downloads (last 30 days)
|
||||
// Get total downloads (last 30 days) - include both single and bulk downloads
|
||||
const totalDownloads = await db('access_logs')
|
||||
.where('action', 'download')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
@@ -73,7 +73,7 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
.first();
|
||||
|
||||
const previousDownloads = await db('access_logs')
|
||||
.where('action', 'download')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
||||
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
@@ -243,10 +243,10 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
// Get downloads per day
|
||||
// Get downloads per day - include both single and bulk downloads
|
||||
const downloadsData = await db('access_logs')
|
||||
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
||||
.where('action', 'download')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
@@ -272,14 +272,15 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
||||
if (dateObj) dateObj.uniqueVisitors = row.count;
|
||||
});
|
||||
|
||||
// Get top galleries by views
|
||||
// Get top galleries by views with additional metrics
|
||||
const topGalleries = await db('access_logs')
|
||||
.select('events.event_name', 'events.slug')
|
||||
.select(db.raw('COUNT(*) as views'))
|
||||
.select('events.id', 'events.event_name', 'events.slug')
|
||||
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
|
||||
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
|
||||
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
|
||||
.join('events', 'access_logs.event_id', 'events.id')
|
||||
.where('access_logs.action', 'view')
|
||||
.where('access_logs.timestamp', '>=', startDateStr)
|
||||
.groupBy('events.id')
|
||||
.groupBy('events.id', 'events.event_name', 'events.slug')
|
||||
.orderBy('views', 'desc')
|
||||
.limit(5);
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ const router = express.Router();
|
||||
// Get public settings (branding and theme)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// Fetch branding, theme, general, and select security settings
|
||||
// Fetch branding, theme, general, security, and analytics settings
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
|
||||
.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
// Convert to object format
|
||||
@@ -41,7 +41,12 @@ router.get('/', async (req, res) => {
|
||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true'
|
||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
||||
// Umami analytics configuration (only if enabled)
|
||||
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
|
||||
umami_url: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_url || null) : null,
|
||||
umami_website_id: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_website_id || null) : null,
|
||||
umami_share_url: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_share_url || null) : null
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.65",
|
||||
"version": "1.0.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.65",
|
||||
"version": "1.0.67",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.65",
|
||||
"version": "1.0.67",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+27
-13
@@ -44,17 +44,26 @@ function App() {
|
||||
// Initialize Umami Analytics based on settings
|
||||
useEffect(() => {
|
||||
const initializeAnalytics = async () => {
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
if (umamiUrl && umamiWebsiteId) {
|
||||
try {
|
||||
// Fetch public settings to check if analytics is enabled
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
const settings = await response.json();
|
||||
try {
|
||||
// Fetch public settings to get Umami configuration
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
const settings = await response.json();
|
||||
|
||||
// Check if Umami is enabled and configured in backend settings
|
||||
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
|
||||
// Use backend configuration
|
||||
analyticsService.initialize({
|
||||
websiteId: settings.umami_website_id,
|
||||
hostUrl: settings.umami_url,
|
||||
autoTrack: true,
|
||||
doNotTrack: true
|
||||
});
|
||||
} else {
|
||||
// Fall back to environment variables if backend not configured
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
// Only initialize if analytics is enabled in settings
|
||||
if (settings.enable_analytics !== false) {
|
||||
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
|
||||
analyticsService.initialize({
|
||||
websiteId: umamiWebsiteId,
|
||||
hostUrl: umamiUrl,
|
||||
@@ -62,9 +71,14 @@ function App() {
|
||||
doNotTrack: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings for analytics:', error);
|
||||
// Initialize analytics anyway if settings fetch fails
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings for analytics:', error);
|
||||
// Fall back to environment variables on error
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
if (umamiUrl && umamiWebsiteId) {
|
||||
analyticsService.initialize({
|
||||
websiteId: umamiWebsiteId,
|
||||
hostUrl: umamiUrl,
|
||||
|
||||
@@ -494,6 +494,30 @@
|
||||
"sent": "Sent",
|
||||
"failed": "Failed",
|
||||
"lastUpdate": "Last update"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"umamiIntegration": "Umami Analytics Integration",
|
||||
"enableUmami": "Enable Umami Analytics",
|
||||
"umamiUrl": "Umami URL",
|
||||
"umamiUrlHelp": "The URL of your Umami instance (e.g., https://analytics.yourdomain.com)",
|
||||
"websiteId": "Website ID",
|
||||
"websiteIdHelp": "Your Umami website ID (found in Umami dashboard)",
|
||||
"shareUrl": "Share URL (Optional)",
|
||||
"shareUrlHelp": "Public share URL for embedding the full dashboard (create in Umami)",
|
||||
"umamiInfo": "About Umami Analytics",
|
||||
"umamiInfoText": "Umami is a privacy-focused, open-source analytics platform. It tracks page views, unique visitors, and custom events without using cookies.",
|
||||
"learnMore": "Learn more about Umami",
|
||||
"saveAnalyticsSettings": "Save Analytics Settings",
|
||||
"backendAnalytics": "Backend Analytics",
|
||||
"backendAnalyticsText": "The system also tracks basic analytics server-side for security and performance monitoring.",
|
||||
"tracked": "Tracked Metrics",
|
||||
"galleryViews": "Gallery page views",
|
||||
"photoDownloads": "Individual and bulk downloads",
|
||||
"uniqueVisitors": "Unique visitors by IP",
|
||||
"deviceTypes": "Device types from user agents",
|
||||
"privacy": "Privacy",
|
||||
"privacyText": "IP addresses are hashed for privacy. No personal data is stored. Analytics data is retained for 90 days."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
@@ -52,10 +52,8 @@ export const AnalyticsPage: React.FC = () => {
|
||||
const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('7d');
|
||||
const [isEmbedMode, setIsEmbedMode] = useState(false);
|
||||
|
||||
// Check if Umami is configured
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
// const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
const umamiShareUrl = import.meta.env.VITE_UMAMI_SHARE_URL;
|
||||
// Check if Umami is configured from settings or environment
|
||||
const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string }>({});
|
||||
|
||||
// Fetch analytics data from backend
|
||||
const { data: apiData, isLoading, refetch } = useQuery({
|
||||
@@ -73,6 +71,38 @@ export const AnalyticsPage: React.FC = () => {
|
||||
queryFn: () => adminService.getDashboardStats(),
|
||||
});
|
||||
|
||||
// Fetch public settings to get Umami config
|
||||
useEffect(() => {
|
||||
const fetchUmamiConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`);
|
||||
const settings = await response.json();
|
||||
|
||||
if (settings.umami_enabled) {
|
||||
setUmamiConfig({
|
||||
url: settings.umami_url,
|
||||
shareUrl: settings.umami_share_url
|
||||
});
|
||||
} else {
|
||||
// Fall back to environment variables
|
||||
setUmamiConfig({
|
||||
url: import.meta.env.VITE_UMAMI_URL,
|
||||
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Umami config:', error);
|
||||
// Fall back to environment variables
|
||||
setUmamiConfig({
|
||||
url: import.meta.env.VITE_UMAMI_URL,
|
||||
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchUmamiConfig();
|
||||
}, []);
|
||||
|
||||
// Calculate trends and format data
|
||||
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
|
||||
if (!apiData) return undefined;
|
||||
@@ -96,11 +126,15 @@ export const AnalyticsPage: React.FC = () => {
|
||||
const secondHalfDownloads = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.downloads, 0);
|
||||
const downloadsTrend = firstHalfDownloads > 0 ? ((secondHalfDownloads - firstHalfDownloads) / firstHalfDownloads) * 100 : 0;
|
||||
|
||||
// Format top galleries for downloads
|
||||
const topGalleriesWithDownloads = apiData.topGalleries.map(gallery => ({
|
||||
name: gallery.event_name,
|
||||
downloads: gallery.views // Using views as download count for now
|
||||
}));
|
||||
// Get actual download data for top galleries - sort by downloads
|
||||
const topGalleriesWithDownloads = apiData.topGalleries
|
||||
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
|
||||
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
|
||||
.slice(0, 5) // Take top 5
|
||||
.map(gallery => ({
|
||||
name: gallery.event_name,
|
||||
downloads: gallery.downloads || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
pageViews: {
|
||||
@@ -122,7 +156,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
topPages: apiData.topGalleries.map(gallery => ({
|
||||
path: `/gallery/${gallery.slug}`,
|
||||
views: gallery.views,
|
||||
uniqueVisitors: Math.round(gallery.views * 0.4) // Estimate unique visitors
|
||||
uniqueVisitors: gallery.uniqueVisitors || gallery.views // Use actual unique visitors if available
|
||||
}))
|
||||
};
|
||||
}, [apiData]);
|
||||
@@ -166,7 +200,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// If Umami is configured and embed mode is enabled, show the Umami dashboard
|
||||
if (isEmbedMode && umamiShareUrl) {
|
||||
if (isEmbedMode && umamiConfig.shareUrl) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -185,7 +219,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
|
||||
<Card padding="none" className="overflow-hidden" style={{ height: '800px' }}>
|
||||
<iframe
|
||||
src={umamiShareUrl}
|
||||
src={umamiConfig.shareUrl}
|
||||
className="w-full h-full border-0"
|
||||
title="Umami Analytics Dashboard"
|
||||
/>
|
||||
@@ -203,7 +237,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
<p className="text-neutral-600 mt-1">{t('analytics.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{umamiShareUrl && (
|
||||
{umamiConfig.shareUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEmbedMode(true)}
|
||||
@@ -405,7 +439,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Configuration Notice */}
|
||||
{!umamiUrl && (
|
||||
{!umamiConfig.url && (
|
||||
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
||||
|
||||
@@ -21,7 +21,7 @@ import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics'>('general');
|
||||
const queryClient = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
@@ -72,6 +72,14 @@ export const SettingsPage: React.FC = () => {
|
||||
recaptcha_secret_key: ''
|
||||
});
|
||||
|
||||
// Analytics settings state
|
||||
const [analyticsSettings, setAnalyticsSettings] = useState({
|
||||
umami_enabled: false,
|
||||
umami_url: '',
|
||||
umami_website_id: '',
|
||||
umami_share_url: ''
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
// Set the language if it's different from current
|
||||
@@ -104,8 +112,16 @@ export const SettingsPage: React.FC = () => {
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key || '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
|
||||
});
|
||||
|
||||
// Extract analytics settings
|
||||
setAnalyticsSettings({
|
||||
umami_enabled: settings.analytics_umami_enabled || false,
|
||||
umami_url: settings.analytics_umami_url || '',
|
||||
umami_website_id: settings.analytics_umami_website_id || '',
|
||||
umami_share_url: settings.analytics_umami_share_url || ''
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
}, [settings, i18n]);
|
||||
|
||||
// Save mutations
|
||||
const saveGeneralMutation = useMutation({
|
||||
@@ -144,6 +160,24 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const saveAnalyticsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Convert to the format expected by the API
|
||||
const settingsData: Record<string, any> = {};
|
||||
Object.entries(analyticsSettings).forEach(([key, value]) => {
|
||||
settingsData[`analytics_${key}`] = value;
|
||||
});
|
||||
return settingsService.updateSettings(settingsData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.settingsSaved'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -202,6 +236,16 @@ export const SettingsPage: React.FC = () => {
|
||||
>
|
||||
{t('settings.categories.title')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'analytics'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{t('settings.analytics.title')}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -754,6 +798,127 @@ export const SettingsPage: React.FC = () => {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={analyticsSettings.umami_enabled}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.analytics.enableUmami')}</span>
|
||||
</label>
|
||||
|
||||
{analyticsSettings.umami_enabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.analytics.umamiUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.umami_url}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
|
||||
placeholder="https://analytics.yourdomain.com"
|
||||
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.analytics.umamiUrlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.analytics.websiteId')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={analyticsSettings.umami_website_id}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.analytics.websiteIdHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.analytics.shareUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.umami_share_url}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
|
||||
placeholder="https://analytics.yourdomain.com/share/..."
|
||||
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.analytics.shareUrlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
|
||||
<p>{t('settings.analytics.umamiInfoText')}</p>
|
||||
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
|
||||
{t('settings.analytics.learnMore')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => saveAnalyticsMutation.mutate()}
|
||||
isLoading={saveAnalyticsMutation.isPending}
|
||||
leftIcon={<Save className="w-5 h-5" />}
|
||||
>
|
||||
{t('settings.analytics.saveAnalyticsSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Backend Analytics Info */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
|
||||
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
|
||||
<ul className="text-xs text-neutral-600 space-y-1">
|
||||
<li>• {t('settings.analytics.galleryViews')}</li>
|
||||
<li>• {t('settings.analytics.photoDownloads')}</li>
|
||||
<li>• {t('settings.analytics.uniqueVisitors')}</li>
|
||||
<li>• {t('settings.analytics.deviceTypes')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
|
||||
<p className="text-xs text-neutral-600">
|
||||
{t('settings.analytics.privacyText')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user