242 lines
9.9 KiB
TypeScript
242 lines
9.9 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { toast } from 'react-toastify';
|
|
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
|
|
import { Button, Card, Loading } from '../common';
|
|
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
|
|
|
|
export const CssTemplateEditor: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [activeSlot, setActiveSlot] = useState(1);
|
|
const [localTemplates, setLocalTemplates] = useState<CssTemplate[]>([]);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
|
|
// Fetch templates
|
|
const { data: templates, isLoading } = useQuery({
|
|
queryKey: ['css-templates'],
|
|
queryFn: () => cssTemplatesService.getTemplates()
|
|
});
|
|
|
|
// Update local state when templates load
|
|
useEffect(() => {
|
|
if (templates) {
|
|
setLocalTemplates(templates);
|
|
setHasChanges(false);
|
|
}
|
|
}, [templates]);
|
|
|
|
// Save mutation
|
|
const saveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const template = localTemplates.find(t => t.slot_number === activeSlot);
|
|
if (!template) throw new Error('Template not found');
|
|
|
|
return cssTemplatesService.updateTemplate(activeSlot, {
|
|
name: template.name,
|
|
css_content: template.css_content,
|
|
is_enabled: template.is_enabled
|
|
});
|
|
},
|
|
onSuccess: (result) => {
|
|
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
|
|
setHasChanges(false);
|
|
|
|
if (result.warnings.length > 0) {
|
|
toast.warning(t('cssTemplates.sanitizationWarning', 'Some CSS patterns were blocked for security'));
|
|
} else {
|
|
toast.success(t('cssTemplates.saved', 'Template saved successfully'));
|
|
}
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(error.message || t('cssTemplates.saveFailed', 'Failed to save template'));
|
|
}
|
|
});
|
|
|
|
// Reset mutation
|
|
const resetMutation = useMutation({
|
|
mutationFn: () => cssTemplatesService.resetToDefault(),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
|
|
toast.success(t('cssTemplates.reset', 'Template reset to default'));
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(error.message || t('cssTemplates.resetFailed', 'Failed to reset template'));
|
|
}
|
|
});
|
|
|
|
const activeTemplate = localTemplates.find(t => t.slot_number === activeSlot);
|
|
|
|
const updateLocalTemplate = (updates: Partial<CssTemplate>) => {
|
|
setLocalTemplates(prev =>
|
|
prev.map(t =>
|
|
t.slot_number === activeSlot ? { ...t, ...updates } : t
|
|
)
|
|
);
|
|
setHasChanges(true);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
if (!confirm(t('cssTemplates.resetConfirm', 'Reset this template to the default? Your changes will be lost.'))) {
|
|
return;
|
|
}
|
|
resetMutation.mutate();
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <Loading size="lg" text={t('common.loading', 'Loading...')} />;
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
|
<Code className="w-5 h-5" />
|
|
{t('cssTemplates.title', 'Custom CSS Templates')}
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="flex border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
|
{[1, 2, 3].map(slot => {
|
|
const template = localTemplates.find(t => t.slot_number === slot);
|
|
return (
|
|
<button
|
|
key={slot}
|
|
onClick={() => setActiveSlot(slot)}
|
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeSlot === slot
|
|
? 'border-accent text-accent'
|
|
: 'border-transparent text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:border-neutral-300 dark:hover:border-neutral-600'
|
|
}`}
|
|
>
|
|
{t('cssTemplates.template', 'Template')} {slot}
|
|
{template && (
|
|
<span className="ml-2 text-neutral-400">
|
|
({template.name})
|
|
</span>
|
|
)}
|
|
{template?.is_enabled && (
|
|
<Check className="w-3 h-3 inline ml-1 text-green-500" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{activeTemplate && (
|
|
<div className="space-y-6">
|
|
{/* Template Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 dark:text-neutral-300 mb-2">
|
|
{t('cssTemplates.templateName', 'Template Name')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={activeTemplate.name}
|
|
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
|
|
maxLength={50}
|
|
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
|
/>
|
|
</div>
|
|
|
|
{/* Enable Toggle */}
|
|
<div>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={activeTemplate.is_enabled}
|
|
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
|
|
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
|
|
/>
|
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
{t('cssTemplates.enableTemplate', 'Enable this template')}
|
|
</span>
|
|
</label>
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
|
|
{t('cssTemplates.enableHint', 'Enabled templates can be selected when creating events')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* CSS Editor */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 dark:text-neutral-300 mb-2">
|
|
{t('cssTemplates.cssContent', 'CSS Content')}
|
|
</label>
|
|
<div className="relative">
|
|
<textarea
|
|
value={activeTemplate.css_content}
|
|
onChange={(e) => updateLocalTemplate({ css_content: e.target.value })}
|
|
className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark bg-neutral-900 text-green-400"
|
|
spellCheck={false}
|
|
placeholder="/* Enter your custom CSS here */"
|
|
/>
|
|
<div className="absolute bottom-3 right-3 text-xs text-neutral-400">
|
|
{(activeTemplate.css_content?.length || 0).toLocaleString()} / 102,400 {t('common.characters', 'characters')}
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
|
{t('cssTemplates.cssHint', 'Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Security Notice */}
|
|
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
|
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
|
<div className="text-xs text-amber-800 dark:text-amber-200">
|
|
<strong>{t('cssTemplates.securityNotice', 'Security Notice')}:</strong>{' '}
|
|
{t('cssTemplates.securityText', 'CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center justify-between pt-4 border-t border-neutral-100 dark:border-neutral-700">
|
|
<div className="flex items-center gap-3">
|
|
{activeSlot === 1 && activeTemplate.is_default && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleReset}
|
|
disabled={resetMutation.isPending}
|
|
leftIcon={<RotateCcw className="w-4 h-4" />}
|
|
>
|
|
{t('cssTemplates.resetToDefault', 'Reset to Default')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{hasChanges && (
|
|
<span className="text-sm text-amber-600">
|
|
{t('cssTemplates.unsavedChanges', 'Unsaved changes')}
|
|
</span>
|
|
)}
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => saveMutation.mutate()}
|
|
disabled={saveMutation.isPending || !hasChanges}
|
|
isLoading={saveMutation.isPending}
|
|
leftIcon={<Save className="w-4 h-4" />}
|
|
>
|
|
{t('cssTemplates.saveTemplate', 'Save Template')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Last Updated */}
|
|
{activeTemplate.updated_at && (
|
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 text-right">
|
|
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default CssTemplateEditor;
|