|
|
@@ -0,0 +1,441 @@
|
|
|
+import React, { useState, useEffect } from 'react';
|
|
|
+import { Link, useParams, useNavigate } from 'react-router-dom';
|
|
|
+import { useTheme } from '../contexts/ThemeContext';
|
|
|
+
|
|
|
+function ThemeEditor() {
|
|
|
+ const { themeId } = useParams();
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const isEditing = !!themeId;
|
|
|
+
|
|
|
+ const {
|
|
|
+ currentTheme,
|
|
|
+ allThemes,
|
|
|
+ createTheme,
|
|
|
+ updateTheme,
|
|
|
+ setActiveTheme,
|
|
|
+ exportTheme,
|
|
|
+ importTheme
|
|
|
+ } = useTheme();
|
|
|
+
|
|
|
+ const [formData, setFormData] = useState({
|
|
|
+ name: '',
|
|
|
+ id: '',
|
|
|
+ colors: {
|
|
|
+ primary: '#3b82f6',
|
|
|
+ primaryHover: '#2563eb',
|
|
|
+ secondary: '#6b7280',
|
|
|
+ background: '#ffffff',
|
|
|
+ surface: '#f9fafb',
|
|
|
+ text: '#1f2937',
|
|
|
+ textSecondary: '#6b7280',
|
|
|
+ border: '#e5e7eb',
|
|
|
+ accent: '#10b981',
|
|
|
+ error: '#ef4444',
|
|
|
+ warning: '#f59e0b',
|
|
|
+ success: '#10b981'
|
|
|
+ },
|
|
|
+ typography: {
|
|
|
+ fontFamily: 'Inter, system-ui, sans-serif',
|
|
|
+ headingFontFamily: 'Inter, system-ui, sans-serif'
|
|
|
+ },
|
|
|
+ customCSS: ''
|
|
|
+ });
|
|
|
+
|
|
|
+ const [saving, setSaving] = useState(false);
|
|
|
+ const [error, setError] = useState(null);
|
|
|
+ const [previewMode, setPreviewMode] = useState(false);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (isEditing && themeId) {
|
|
|
+ const theme = allThemes.find(t => t.id === themeId);
|
|
|
+ if (theme) {
|
|
|
+ setFormData({
|
|
|
+ name: theme.name,
|
|
|
+ id: theme.id,
|
|
|
+ colors: theme.colors,
|
|
|
+ typography: theme.typography,
|
|
|
+ customCSS: theme.customCSS || ''
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [isEditing, themeId, allThemes]);
|
|
|
+
|
|
|
+ const handleInputChange = (field, value, section = null) => {
|
|
|
+ if (section) {
|
|
|
+ setFormData(prev => ({
|
|
|
+ ...prev,
|
|
|
+ [section]: {
|
|
|
+ ...prev[section],
|
|
|
+ [field]: value
|
|
|
+ }
|
|
|
+ }));
|
|
|
+ } else {
|
|
|
+ setFormData(prev => ({ ...prev, [field]: value }));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSubmit = async (e) => {
|
|
|
+ e.preventDefault();
|
|
|
+
|
|
|
+ if (!formData.name.trim()) {
|
|
|
+ setError('Theme name is required');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isEditing && !formData.id.trim()) {
|
|
|
+ setError('Theme ID is required');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setSaving(true);
|
|
|
+ setError(null);
|
|
|
+
|
|
|
+ try {
|
|
|
+ let result;
|
|
|
+
|
|
|
+ if (isEditing) {
|
|
|
+ result = await updateTheme(themeId, formData);
|
|
|
+ } else {
|
|
|
+ result = await createTheme(formData);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (result.success) {
|
|
|
+ navigate('/admin/themes');
|
|
|
+ } else {
|
|
|
+ setError(result.error);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ setError('Failed to save theme');
|
|
|
+ } finally {
|
|
|
+ setSaving(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handlePreview = () => {
|
|
|
+ setPreviewMode(!previewMode);
|
|
|
+ if (!previewMode) {
|
|
|
+ // Apply preview theme
|
|
|
+ const root = document.documentElement;
|
|
|
+ Object.entries(formData.colors).forEach(([key, value]) => {
|
|
|
+ const cssVar = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
|
|
+ root.style.setProperty(cssVar, value);
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ // Restore current theme
|
|
|
+ if (currentTheme) {
|
|
|
+ Object.entries(currentTheme.colors).forEach(([key, value]) => {
|
|
|
+ const cssVar = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
|
|
+ root.style.setProperty(cssVar, value);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleImport = () => {
|
|
|
+ const input = document.createElement('input');
|
|
|
+ input.type = 'file';
|
|
|
+ input.accept = '.json';
|
|
|
+ input.onchange = async (e) => {
|
|
|
+ const file = e.target.files[0];
|
|
|
+ if (!file) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const text = await file.text();
|
|
|
+ const themeData = JSON.parse(text);
|
|
|
+
|
|
|
+ const result = await importTheme(themeData);
|
|
|
+ if (result.success) {
|
|
|
+ navigate('/admin/themes');
|
|
|
+ } else {
|
|
|
+ setError(result.error);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ setError('Invalid theme file');
|
|
|
+ }
|
|
|
+ };
|
|
|
+ input.click();
|
|
|
+ };
|
|
|
+
|
|
|
+ const ColorInput = ({ label, value, onChange, description }) => (
|
|
|
+ <div className="space-y-2">
|
|
|
+ <label className="block text-sm font-medium text-gray-700">
|
|
|
+ {label}
|
|
|
+ {description && <span className="text-xs text-gray-500 block">{description}</span>}
|
|
|
+ </label>
|
|
|
+ <div className="flex items-center space-x-3">
|
|
|
+ <input
|
|
|
+ type="color"
|
|
|
+ value={value}
|
|
|
+ onChange={(e) => onChange(e.target.value)}
|
|
|
+ className="h-10 w-16 rounded border border-gray-300 cursor-pointer"
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={value}
|
|
|
+ onChange={(e) => onChange(e.target.value)}
|
|
|
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
|
|
|
+ placeholder="#000000"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="min-h-screen bg-gray-50">
|
|
|
+ <div className="max-w-4xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="bg-white shadow rounded-lg mb-6">
|
|
|
+ <div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-2xl font-bold text-gray-900">
|
|
|
+ {isEditing ? 'Edit Theme' : 'Create New Theme'}
|
|
|
+ </h1>
|
|
|
+ <p className="text-gray-600">
|
|
|
+ {isEditing ? 'Modify your existing theme' : 'Design a custom theme for your blog'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div className="flex space-x-3">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={handlePreview}
|
|
|
+ className={`px-4 py-2 rounded-lg font-medium ${
|
|
|
+ previewMode
|
|
|
+ ? 'bg-orange-100 text-orange-700 hover:bg-orange-200'
|
|
|
+ : 'bg-purple-100 text-purple-700 hover:bg-purple-200'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {previewMode ? 'Stop Preview' : 'Preview'}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={handleImport}
|
|
|
+ className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors"
|
|
|
+ >
|
|
|
+ Import Theme
|
|
|
+ </button>
|
|
|
+ <Link
|
|
|
+ to="/admin/themes"
|
|
|
+ className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"
|
|
|
+ >
|
|
|
+ Back to Themes
|
|
|
+ </Link>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {previewMode && (
|
|
|
+ <div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
|
|
|
+ <div className="flex items-center">
|
|
|
+ <svg className="h-5 w-5 text-purple-400" fill="currentColor" viewBox="0 0 20 20">
|
|
|
+ <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
|
|
+ </svg>
|
|
|
+ <p className="ml-3 text-sm text-purple-700">
|
|
|
+ <strong>Preview Mode Active:</strong> You're seeing a live preview of your theme changes.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {error && (
|
|
|
+ <div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
|
|
+ <div className="flex">
|
|
|
+ <svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
|
+ <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
|
|
+ </svg>
|
|
|
+ <p className="ml-3 text-sm text-red-700">{error}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <form onSubmit={handleSubmit} className="space-y-6">
|
|
|
+ {/* Basic Info */}
|
|
|
+ <div className="bg-white shadow rounded-lg">
|
|
|
+ <div className="px-6 py-4 border-b border-gray-200">
|
|
|
+ <h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
|
|
|
+ </div>
|
|
|
+ <div className="px-6 py-4 space-y-6">
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
+ <div>
|
|
|
+ <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
|
|
+ Theme Name *
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ id="name"
|
|
|
+ required
|
|
|
+ value={formData.name}
|
|
|
+ onChange={(e) => handleInputChange('name', e.target.value)}
|
|
|
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
|
+ placeholder="My Custom Theme"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label htmlFor="id" className="block text-sm font-medium text-gray-700 mb-1">
|
|
|
+ Theme ID {!isEditing && '*'}
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ id="id"
|
|
|
+ required={!isEditing}
|
|
|
+ disabled={isEditing}
|
|
|
+ value={formData.id}
|
|
|
+ onChange={(e) => handleInputChange('id', e.target.value)}
|
|
|
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
|
|
+ placeholder="my-custom-theme"
|
|
|
+ />
|
|
|
+ <p className="mt-1 text-xs text-gray-500">
|
|
|
+ {isEditing ? 'ID cannot be changed after creation' : 'Unique identifier (lowercase, hyphens allowed)'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Colors */}
|
|
|
+ <div className="bg-white shadow rounded-lg">
|
|
|
+ <div className="px-6 py-4 border-b border-gray-200">
|
|
|
+ <h2 className="text-lg font-semibold text-gray-900">Colors</h2>
|
|
|
+ <p className="text-sm text-gray-600">Customize the color palette for your theme</p>
|
|
|
+ </div>
|
|
|
+ <div className="px-6 py-4">
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
+ <ColorInput
|
|
|
+ label="Primary Color"
|
|
|
+ description="Main brand color for buttons and links"
|
|
|
+ value={formData.colors.primary}
|
|
|
+ onChange={(value) => handleInputChange('primary', value, 'colors')}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ColorInput
|
|
|
+ label="Primary Hover"
|
|
|
+ description="Hover state for primary elements"
|
|
|
+ value={formData.colors.primaryHover}
|
|
|
+ onChange={(value) => handleInputChange('primaryHover', value, 'colors')}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ColorInput
|
|
|
+ label="Background"
|
|
|
+ description="Main page background color"
|
|
|
+ value={formData.colors.background}
|
|
|
+ onChange={(value) => handleInputChange('background', value, 'colors')}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ColorInput
|
|
|
+ label="Surface"
|
|
|
+ description="Cards and panel backgrounds"
|
|
|
+ value={formData.colors.surface}
|
|
|
+ onChange={(value) => handleInputChange('surface', value, 'colors')}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ColorInput
|
|
|
+ label="Text Color"
|
|
|
+ description="Primary text color"
|
|
|
+ value={formData.colors.text}
|
|
|
+ onChange={(value) => handleInputChange('text', value, 'colors')}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ColorInput
|
|
|
+ label="Secondary Text"
|
|
|
+ description="Muted text and descriptions"
|
|
|
+ value={formData.colors.textSecondary}
|
|
|
+ onChange={(value) => handleInputChange('textSecondary', value, 'colors')}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ColorInput
|
|
|
+ label="Border Color"
|
|
|
+ description="Borders and dividers"
|
|
|
+ value={formData.colors.border}
|
|
|
+ onChange={(value) => handleInputChange('border', value, 'colors')}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ColorInput
|
|
|
+ label="Accent Color"
|
|
|
+ description="Accent elements and highlights"
|
|
|
+ value={formData.colors.accent}
|
|
|
+ onChange={(value) => handleInputChange('accent', value, 'colors')}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Typography */}
|
|
|
+ <div className="bg-white shadow rounded-lg">
|
|
|
+ <div className="px-6 py-4 border-b border-gray-200">
|
|
|
+ <h2 className="text-lg font-semibold text-gray-900">Typography</h2>
|
|
|
+ <p className="text-sm text-gray-600">Font settings for your theme</p>
|
|
|
+ </div>
|
|
|
+ <div className="px-6 py-4 space-y-6">
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-1">
|
|
|
+ Body Font Family
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={formData.typography.fontFamily}
|
|
|
+ onChange={(e) => handleInputChange('fontFamily', e.target.value, 'typography')}
|
|
|
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
|
|
+ placeholder="Inter, system-ui, sans-serif"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-1">
|
|
|
+ Heading Font Family
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={formData.typography.headingFontFamily}
|
|
|
+ onChange={(e) => handleInputChange('headingFontFamily', e.target.value, 'typography')}
|
|
|
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
|
|
+ placeholder="Inter, system-ui, sans-serif"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Custom CSS */}
|
|
|
+ <div className="bg-white shadow rounded-lg">
|
|
|
+ <div className="px-6 py-4 border-b border-gray-200">
|
|
|
+ <h2 className="text-lg font-semibold text-gray-900">Custom CSS</h2>
|
|
|
+ <p className="text-sm text-gray-600">Add custom CSS for advanced styling</p>
|
|
|
+ </div>
|
|
|
+ <div className="px-6 py-4">
|
|
|
+ <textarea
|
|
|
+ rows={10}
|
|
|
+ value={formData.customCSS}
|
|
|
+ onChange={(e) => handleInputChange('customCSS', e.target.value)}
|
|
|
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
|
|
+ placeholder="/* Custom CSS rules */ .my-custom-class { /* your styles */ }"
|
|
|
+ />
|
|
|
+ <p className="mt-2 text-xs text-gray-500">
|
|
|
+ Use CSS custom properties like <code>var(--color-primary)</code> to reference theme colors
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Actions */}
|
|
|
+ <div className="flex justify-end space-x-4">
|
|
|
+ <Link
|
|
|
+ to="/admin/themes"
|
|
|
+ className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
|
|
+ >
|
|
|
+ Cancel
|
|
|
+ </Link>
|
|
|
+ <button
|
|
|
+ type="submit"
|
|
|
+ disabled={saving}
|
|
|
+ className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ >
|
|
|
+ {saving ? 'Saving...' : (isEditing ? 'Update Theme' : 'Create Theme')}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export default ThemeEditor;
|