diff --git a/demo/index.tsx b/demo/index.tsx index 8f00e0d88..7a1d02b61 100644 --- a/demo/index.tsx +++ b/demo/index.tsx @@ -2,31 +2,42 @@ import ReactDOM from 'react-dom/client'; import { createGlobalStyle } from 'styled-components'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; -import Example from './src/example'; -import Dashboard from './src/dashboard'; +import { I18nProvider } from './src/i18n'; +import DesignList from './src/dashboard/DesignList'; +import DesignEdit from './src/dashboard/DesignEdit'; const GlobalStyle = createGlobalStyle` + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + html, body { margin: 0; padding: 0; height: 100%; - font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } #root { height: 100%; } + + *, *::before, *::after { + box-sizing: border-box; + } `; const App = () => { return ( - - - - } /> - } /> - - + + + + + } /> + } /> + + + ); }; diff --git a/demo/src/dashboard/DesignEdit.tsx b/demo/src/dashboard/DesignEdit.tsx index 563cadc0c..d55a2719b 100644 --- a/demo/src/dashboard/DesignEdit.tsx +++ b/demo/src/dashboard/DesignEdit.tsx @@ -1,99 +1,380 @@ -import React, { useRef } from 'react'; -import styled from 'styled-components'; -import { Link } from 'react-router-dom'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import styled, { keyframes } from 'styled-components'; +import { useParams, useNavigate } from 'react-router-dom'; -import EmailEditor, { EditorRef } from '../../../src'; // use react-email-editor instead +import EmailEditor, { EditorRef, EmailEditorProps } from '../../../src'; // use react-email-editor instead +import { getDesign, updateDesignJson, renameDesign } from '../store'; +import { useI18n, LanguageToggle } from '../i18n'; +import { editorTranslations } from '../editorTranslations'; + +/* ─── Animations ─── */ + +const fadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } +`; + +const slideDown = keyframes` + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +`; + +/* ─── Styled Components ─── */ const Container = styled.div` display: flex; flex-direction: column; position: relative; height: 100%; + animation: ${fadeIn} .3s ease-out; `; const Bar = styled.div` + background: #0f0f1f; + border-bottom: 1px solid rgba(255,255,255,.08); + color: #e2e8f0; + padding: 0 20px; + display: flex; + align-items: center; + height: 56px; + gap: 12px; + flex-shrink: 0; +`; + +const BackBtn = styled.button` + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid rgba(255,255,255,.12); + border-radius: 10px; + background: rgba(255,255,255,.05); + color: #94a3b8; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all .2s; + flex-shrink: 0; + font-family: inherit; + + &:hover { + background: rgba(255,255,255,.1); + color: #e2e8f0; + border-color: rgba(255,255,255,.2); + } + + svg { + width: 16px; + height: 16px; + } +`; + +const NameSection = styled.div` flex: 1; - background-color: #61dafb; - color: #000; - padding: 10px; display: flex; - max-height: 40px; + align-items: center; + gap: 8px; + min-width: 0; +`; + +const DesignName = styled.h1` + font-size: 15px; + font-weight: 600; + color: #e2e8f0; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: background .2s; + + &:hover { + background: rgba(255,255,255,.06); + } +`; - h1 { - flex: 1; - font-size: 16px; - text-align: left; +const NameInput = styled.input` + font-size: 15px; + font-weight: 600; + color: #e2e8f0; + background: rgba(255,255,255,.08); + border: 1px solid rgba(99,102,241,.5); + border-radius: 6px; + padding: 4px 8px; + outline: none; + font-family: inherit; + max-width: 300px; + + &:focus { + border-color: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139,92,246,.2); } +`; + +const ActionButton = styled.button<{ $primary?: boolean }>` + display: flex; + align-items: center; + gap: 6px; + padding: 8px 18px; + border: ${(p) => p.$primary ? 'none' : '1px solid rgba(255,255,255,.12)'}; + border-radius: 10px; + background: ${(p) => p.$primary ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : 'rgba(255,255,255,.05)'}; + color: ${(p) => p.$primary ? '#fff' : '#94a3b8'}; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all .2s; + flex-shrink: 0; + font-family: inherit; - button { - flex: 1; - padding: 10px; - margin-left: 10px; - font-size: 14px; - font-weight: bold; - background-color: #000; - color: #fff; - border: 0px; - max-width: 150px; - cursor: pointer; + ${(p) => p.$primary && ` + box-shadow: 0 2px 12px rgba(99,102,241,.3); + `} + + &:hover { + transform: translateY(-1px); + ${(p) => p.$primary + ? 'box-shadow: 0 4px 20px rgba(99,102,241,.4);' + : 'background: rgba(255,255,255,.1); color: #e2e8f0;'} + } + &:active { + transform: translateY(0); } - a { - flex: 1; - padding: 10px; - margin-left: 10px; - font-size: 14px; - font-weight: bold; - color: #fff; - border: 0px; - cursor: pointer; - text-align: right; - text-decoration: none; - line-height: 160%; + svg { + width: 15px; + height: 15px; } `; +const SavedToast = styled.div` + position: fixed; + top: 70px; + right: 24px; + background: #10b981; + color: #fff; + padding: 10px 20px; + border-radius: 10px; + font-size: 13px; + font-weight: 600; + z-index: 9999; + animation: ${slideDown} .3s ease-out; + box-shadow: 0 4px 20px rgba(16,185,129,.3); + display: flex; + align-items: center; + gap: 8px; +`; + +/* ─── Component ─── */ + const DesignEdit = () => { + const { designId } = useParams<{ designId: string }>(); + const navigate = useNavigate(); const emailEditorRef = useRef(null); + const [designName, setDesignName] = useState(''); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const [showSaved, setShowSaved] = useState(false); + const [preview, setPreview] = useState(false); + const nameInputRef = useRef(null); + const designRef = useRef(getDesign(designId || '')); + const { t, locale } = useI18n(); + + useEffect(() => { + if (!designId) { + navigate('/'); + return; + } + const d = getDesign(designId); + if (!d) { + navigate('/'); + return; + } + designRef.current = d; + setDesignName(d.name); + }, [designId, navigate]); + + useEffect(() => { + if (isRenaming && nameInputRef.current) { + nameInputRef.current.focus(); + nameInputRef.current.select(); + } + }, [isRenaming]); + + const onLoad: EmailEditorProps['onLoad'] = useCallback( + (unlayer) => { + const d = designRef.current; + if (d?.json) { + unlayer.loadDesign(d.json); + } + }, + [] + ); + + const onReady: EmailEditorProps['onReady'] = useCallback(() => { + console.log('Editor ready'); + }, []); const saveDesign = () => { const unlayer = emailEditorRef.current?.editor; + if (!unlayer || !designId) return; - unlayer?.saveDesign((design) => { - console.log('saveDesign', design); - alert('Design JSON has been logged in your developer console.'); + unlayer.saveDesign((design) => { + updateDesignJson(designId, design); + setShowSaved(true); + setTimeout(() => setShowSaved(false), 2000); }); }; const exportHtml = () => { const unlayer = emailEditorRef.current?.editor; + if (!unlayer) return; - unlayer?.exportHtml((data) => { + unlayer.exportHtml((data) => { const { html } = data; console.log('exportHtml', html); - alert('Output HTML has been logged in your developer console.'); + const blob = new Blob([html], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${designName || 'email'}.html`; + a.click(); + URL.revokeObjectURL(url); }); }; + const togglePreview = () => { + const unlayer = emailEditorRef.current?.editor; + if (!unlayer) return; + + if (preview) { + unlayer.hidePreview(); + setPreview(false); + } else { + unlayer.showPreview('desktop'); + setPreview(true); + } + }; + + const startRename = () => { + setRenameValue(designName); + setIsRenaming(true); + }; + + const confirmRename = () => { + if (designId && renameValue.trim()) { + renameDesign(designId, renameValue.trim()); + setDesignName(renameValue.trim()); + } + setIsRenaming(false); + }; + + const handleRenameKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') confirmRename(); + if (e.key === 'Escape') setIsRenaming(false); + }; + + const goBack = () => { + const unlayer = emailEditorRef.current?.editor; + if (unlayer && designId) { + unlayer.saveDesign((design) => { + updateDesignJson(designId, design); + navigate('/'); + }); + } else { + navigate('/'); + } + }; + + // Map our locale to Unlayer's supported locale format + const editorLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; + return ( - React Email Editor (Demo) + + + + + + {t('editor.back')} + + + + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={confirmRename} + onKeyDown={handleRenameKey} + /> + ) : ( + + {designName} + + )} + + + - Dashboard - Save Design - Export HTML + + + {preview ? ( + <> + + + > + ) : ( + <> + + + > + )} + + {preview ? t('editor.edit') : t('editor.preview')} + + + + + + + + + {t('editor.save')} + + + + + + + + + {t('editor.export')} + + + {showSaved && ( + + ✓ {t('editor.saved')} + + )} ); }; diff --git a/demo/src/dashboard/DesignList.tsx b/demo/src/dashboard/DesignList.tsx index 4407f61a2..aca234f36 100644 --- a/demo/src/dashboard/DesignList.tsx +++ b/demo/src/dashboard/DesignList.tsx @@ -1,15 +1,501 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled, { keyframes } from 'styled-components'; +import { + getDesigns, + createDesign, + deleteDesign, + renameDesign, + duplicateDesign, + seedSampleIfEmpty, + DesignData, +} from '../store'; +import { useI18n, LanguageToggle } from '../i18n'; +import sample from '../example/sample.json'; + +/* ─── Animations ─── */ + +const fadeInUp = keyframes` + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +`; + +const pulse = keyframes` + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.04); } +`; + +/* ─── Layout ─── */ + +const Page = styled.div` + min-height: 100vh; + background: #0b0b1a; + background-image: + radial-gradient(ellipse 80% 60% at 50% -10%, rgba(99,102,241,.15), transparent), + radial-gradient(ellipse 60% 50% at 80% 100%, rgba(139,92,246,.1), transparent); + padding: 0 24px 80px; +`; + +const Header = styled.header` + max-width: 1120px; + margin: 0 auto; + padding: 48px 0 40px; + display: flex; + align-items: center; + justify-content: space-between; + animation: ${fadeInUp} .5s ease-out; +`; + +const Logo = styled.div` + display: flex; + align-items: center; + gap: 14px; +`; + +const LogoIcon = styled.div` + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + box-shadow: 0 4px 20px rgba(99,102,241,.35); +`; + +const Title = styled.h1` + font-size: 26px; + font-weight: 700; + color: #f1f5f9; + margin: 0; + letter-spacing: -0.5px; +`; + +const Subtitle = styled.span` + font-size: 13px; + color: #64748b; + font-weight: 400; +`; + +const HeaderActions = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const NewButton = styled.button` + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border: none; + border-radius: 12px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all .2s ease; + box-shadow: 0 4px 20px rgba(99,102,241,.3); + font-family: inherit; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 28px rgba(99,102,241,.45); + } + &:active { + transform: translateY(0); + } + + svg { + width: 18px; + height: 18px; + } +`; + +/* ─── Grid ─── */ + +const Grid = styled.div` + max-width: 1120px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; +`; + +const Card = styled.div<{ $index: number }>` + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.07); + border-radius: 16px; + padding: 24px; + cursor: pointer; + transition: all .25s ease; + animation: ${fadeInUp} .45s ease-out both; + animation-delay: ${(p) => p.$index * 60}ms; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 16px; + background: linear-gradient(135deg, rgba(99,102,241,.06), rgba(139,92,246,.03)); + opacity: 0; + transition: opacity .25s; + } + + &:hover { + border-color: rgba(99,102,241,.3); + transform: translateY(-4px); + box-shadow: 0 12px 40px rgba(0,0,0,.3); + + &::before { opacity: 1; } + } +`; + +const CardHeader = styled.div` + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 16px; + position: relative; + z-index: 1; +`; + +const CardIcon = styled.div` + width: 46px; + height: 46px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(99,102,241,.15), rgba(139,92,246,.1)); + border: 1px solid rgba(99,102,241,.2); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +`; + +const CardInfo = styled.div` + flex: 1; + min-width: 0; +`; + +const CardName = styled.h3` + font-size: 16px; + font-weight: 600; + color: #e2e8f0; + margin: 0 0 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const CardDate = styled.p` + font-size: 12px; + color: #64748b; + margin: 0; +`; + +const RenameInput = styled.input` + font-size: 16px; + font-weight: 600; + color: #e2e8f0; + background: rgba(255,255,255,.08); + border: 1px solid rgba(99,102,241,.5); + border-radius: 8px; + padding: 4px 8px; + width: 100%; + outline: none; + font-family: inherit; + + &:focus { + border-color: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139,92,246,.2); + } +`; + +const CardActions = styled.div` + display: flex; + gap: 6px; + position: relative; + z-index: 2; + justify-content: flex-end; +`; + +const ActionBtn = styled.button<{ $danger?: boolean }>` + display: flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + border: 1px solid ${(p) => p.$danger ? 'rgba(239,68,68,.25)' : 'rgba(255,255,255,.1)'}; + border-radius: 8px; + background: ${(p) => p.$danger ? 'rgba(239,68,68,.08)' : 'rgba(255,255,255,.04)'}; + color: ${(p) => p.$danger ? '#f87171' : '#94a3b8'}; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all .2s; + font-family: inherit; + + &:hover { + background: ${(p) => p.$danger ? 'rgba(239,68,68,.15)' : 'rgba(255,255,255,.1)'}; + color: ${(p) => p.$danger ? '#fca5a5' : '#e2e8f0'}; + border-color: ${(p) => p.$danger ? 'rgba(239,68,68,.4)' : 'rgba(255,255,255,.2)'}; + } + + svg { + width: 14px; + height: 14px; + } +`; + +const OpenBadge = styled.div` + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; + padding: 5px 10px; + border-radius: 6px; + background: rgba(99,102,241,.1); + border: 1px solid rgba(99,102,241,.15); + color: #818cf8; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; +`; + +/* ─── Empty state ─── */ + +const EmptyState = styled.div` + max-width: 1120px; + margin: 80px auto; + text-align: center; + animation: ${fadeInUp} .5s ease-out; +`; + +const EmptyIcon = styled.div` + font-size: 56px; + margin-bottom: 20px; + animation: ${pulse} 2.5s ease-in-out infinite; +`; + +const EmptyTitle = styled.h2` + font-size: 22px; + color: #e2e8f0; + margin: 0 0 8px; +`; + +const EmptyText = styled.p` + color: #64748b; + font-size: 15px; + margin: 0 0 28px; +`; + +const StatsBar = styled.div` + max-width: 1120px; + margin: 0 auto 24px; + display: flex; + gap: 8px; + align-items: center; + animation: ${fadeInUp} .5s ease-out .1s both; +`; + +const StatChip = styled.span` + font-size: 13px; + color: #64748b; + padding: 6px 14px; + border-radius: 8px; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.06); +`; + +/* ─── Component ─── */ const DesignList = () => { + const [designs, setDesigns] = useState([]); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const inputRef = useRef(null); + const navigate = useNavigate(); + const { t, dateLocale } = useI18n(); + + useEffect(() => { + seedSampleIfEmpty(sample as any); + setDesigns(getDesigns()); + }, []); + + useEffect(() => { + if (editingId && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editingId]); + + const refresh = () => setDesigns(getDesigns()); + + const handleNew = () => { + const d = createDesign(t('common.newDesignName')); + navigate(`/design/${d.id}`); + }; + + const handleOpen = (id: string) => { + if (editingId) return; + navigate(`/design/${id}`); + }; + + const startRename = (d: DesignData, e: React.MouseEvent) => { + e.stopPropagation(); + setEditingId(d.id); + setEditName(d.name); + }; + + const confirmRename = () => { + if (editingId && editName.trim()) { + renameDesign(editingId, editName.trim()); + refresh(); + } + setEditingId(null); + }; + + const handleRenameKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') confirmRename(); + if (e.key === 'Escape') setEditingId(null); + }; + + const handleDuplicate = (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + duplicateDesign(id, t('common.copySuffix')); + refresh(); + }; + + const handleDelete = (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (confirm(t('dashboard.confirmDelete'))) { + deleteDesign(id); + refresh(); + } + }; + + const formatDate = (ts: number) => + new Date(ts).toLocaleDateString(dateLocale, { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + const sorted = [...designs].sort((a, b) => b.updatedAt - a.updatedAt); + return ( - - My Designs + + + + ✉️ + + {t('app.title')} + {t('app.subtitle')} + + + + + + + + + + + {t('dashboard.newDesign')} + + + + + {sorted.length > 0 && ( + + {t('dashboard.designCount', sorted.length)} + + )} + + {sorted.length === 0 ? ( + + 📭 + {t('dashboard.empty.title')} + {t('dashboard.empty.text')} + + + + + + {t('dashboard.empty.cta')} + + + ) : ( + + {sorted.map((d, i) => ( + handleOpen(d.id)}> + {d.json ? ( + + ● {t('dashboard.template')} + + ) : ( + + ○ {t('dashboard.draft')} + + )} + + + {d.json ? '📧' : '📝'} + + {editingId === d.id ? ( + setEditName(e.target.value)} + onBlur={confirmRename} + onKeyDown={handleRenameKey} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {d.name} + )} + + {t('dashboard.modifiedOn')} {formatDate(d.updatedAt)} + + + - - New Design - - + + startRename(d, e)}> + + + + {t('dashboard.rename')} + + handleDuplicate(d.id, e)}> + + + + + {t('dashboard.duplicate')} + + handleDelete(d.id, e)}> + + + + + {t('dashboard.delete')} + + + + ))} + + )} + ); }; diff --git a/demo/src/editorTranslations.ts b/demo/src/editorTranslations.ts new file mode 100644 index 000000000..927e1510d --- /dev/null +++ b/demo/src/editorTranslations.ts @@ -0,0 +1,43 @@ +export const editorTranslations: Record> = { + fr: { + // Toolbar / block names + text: 'Texte', + image: 'Image', + button: 'Bouton', + divider: 'Séparateur', + heading: 'Titre', + paragraph: 'Paragraphe', + html: 'HTML', + social: 'Réseaux sociaux', + menu: 'Menu', + // Common UI actions + edit: 'Éditer', + delete: 'Supprimer', + duplicate: 'Dupliquer', + copy: 'Copier', + paste: 'Coller', + undo: 'Annuler', + redo: 'Rétablir', + // Dialogs + ok: 'OK', + cancel: 'Annuler', + // Misc + preview: 'Aperçu', + save: 'Sauvegarder', + export: 'Exporter', + // Blocks panel + blockLibrary: 'Bibliothèque de blocs', + components: 'Composants', + // Settings panel + settings: 'Paramètres', + // Labels for fields + link: 'Lien', + target: 'Cible', + altText: 'Texte alternatif', + width: 'Largeur', + height: 'Hauteur', + // Fallback for any missing keys + // Unlayer will fallback to its own default if a key is missing + }, + en: {} +}; diff --git a/demo/src/i18n.tsx b/demo/src/i18n.tsx new file mode 100644 index 000000000..660d0f372 --- /dev/null +++ b/demo/src/i18n.tsx @@ -0,0 +1,180 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + ReactNode, +} from 'react'; +import styled from 'styled-components'; + +/* ─── Types ─── */ + +export type Locale = 'fr' | 'en'; + +type TranslationValue = string | ((...args: any[]) => string); +type Translations = Record; + +/* ─── Translations ─── */ + +const fr: Translations = { + // Dashboard + 'app.title': 'Email Designer', + 'app.subtitle': 'Créez et gérez vos templates d\'emails', + 'dashboard.newDesign': 'Nouveau design', + 'dashboard.designCount': (n: number) => `${n} design${n > 1 ? 's' : ''}`, + 'dashboard.template': 'Template', + 'dashboard.draft': 'Brouillon', + 'dashboard.modifiedOn': 'Modifié le', + 'dashboard.rename': 'Renommer', + 'dashboard.duplicate': 'Dupliquer', + 'dashboard.delete': 'Supprimer', + 'dashboard.confirmDelete': 'Supprimer ce design ?', + 'dashboard.empty.title': 'Aucun design', + 'dashboard.empty.text': 'Créez votre premier template d\'email pour commencer.', + 'dashboard.empty.cta': 'Créer un design', + + // Editor + 'editor.back': 'Retour', + 'editor.preview': 'Aperçu', + 'editor.edit': 'Éditer', + 'editor.save': 'Sauvegarder', + 'editor.export': 'Exporter', + 'editor.saved': 'Design sauvegardé', + 'editor.clickToRename': 'Cliquez pour renommer', + + // Common + 'common.newDesignName': 'Nouveau design', + 'common.copySuffix': '(copie)', +}; + +const en: Translations = { + // Dashboard + 'app.title': 'Email Designer', + 'app.subtitle': 'Create and manage your email templates', + 'dashboard.newDesign': 'New design', + 'dashboard.designCount': (n: number) => `${n} design${n > 1 ? 's' : ''}`, + 'dashboard.template': 'Template', + 'dashboard.draft': 'Draft', + 'dashboard.modifiedOn': 'Modified on', + 'dashboard.rename': 'Rename', + 'dashboard.duplicate': 'Duplicate', + 'dashboard.delete': 'Delete', + 'dashboard.confirmDelete': 'Delete this design?', + 'dashboard.empty.title': 'No designs yet', + 'dashboard.empty.text': 'Create your first email template to get started.', + 'dashboard.empty.cta': 'Create a design', + + // Editor + 'editor.back': 'Back', + 'editor.preview': 'Preview', + 'editor.edit': 'Edit', + 'editor.save': 'Save', + 'editor.export': 'Export', + 'editor.saved': 'Design saved', + 'editor.clickToRename': 'Click to rename', + + // Common + 'common.newDesignName': 'New design', + 'common.copySuffix': '(copy)', +}; + +const allTranslations: Record = { fr, en }; + +/* ─── Context ─── */ + +interface I18nContextType { + locale: Locale; + setLocale: (l: Locale) => void; + t: (key: string, ...args: any[]) => string; + dateLocale: string; +} + +const I18nContext = createContext(null!); + +export const I18nProvider = ({ children }: { children: ReactNode }) => { + const [locale, setLocaleState] = useState(() => { + const saved = localStorage.getItem('app_locale'); + return saved === 'en' || saved === 'fr' ? saved : 'fr'; + }); + + const setLocale = useCallback((l: Locale) => { + setLocaleState(l); + localStorage.setItem('app_locale', l); + }, []); + + const t = useCallback( + (key: string, ...args: any[]): string => { + const val = allTranslations[locale][key]; + if (typeof val === 'function') return val(...args); + if (typeof val === 'string') return val; + return key; // fallback to key + }, + [locale] + ); + + const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; + + return ( + + {children} + + ); +}; + +export const useI18n = () => useContext(I18nContext); + +/* ─── Language Toggle Component ─── */ + +const ToggleWrapper = styled.div` + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 3px; + gap: 2px; + flex-shrink: 0; +`; + +const LangBtn = styled.button<{ $active: boolean }>` + padding: 5px 12px; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + + background: ${(p) => + p.$active + ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' + : 'transparent'}; + color: ${(p) => (p.$active ? '#fff' : '#64748b')}; + box-shadow: ${(p) => + p.$active ? '0 2px 8px rgba(99,102,241,.3)' : 'none'}; + + &:hover { + color: ${(p) => (p.$active ? '#fff' : '#e2e8f0')}; + background: ${(p) => + p.$active + ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' + : 'rgba(255,255,255,.08)'}; + } +`; + +export const LanguageToggle = () => { + const { locale, setLocale } = useI18n(); + + return ( + + setLocale('fr')}> + FR + + setLocale('en')}> + EN + + + ); +}; diff --git a/demo/src/store.ts b/demo/src/store.ts new file mode 100644 index 000000000..2f54daf0b --- /dev/null +++ b/demo/src/store.ts @@ -0,0 +1,78 @@ +export interface DesignData { + id: string; + name: string; + createdAt: number; + updatedAt: number; + json: any; +} + +const STORAGE_KEY = 'email_designs'; + +export function getDesigns(): DesignData[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + return JSON.parse(raw); + } catch { + return []; + } +} + +function saveDesigns(designs: DesignData[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(designs)); +} + +export function getDesign(id: string): DesignData | undefined { + return getDesigns().find((d) => d.id === id); +} + +export function createDesign(name: string, json?: any): DesignData { + const design: DesignData = { + id: crypto.randomUUID(), + name, + createdAt: Date.now(), + updatedAt: Date.now(), + json: json || null, + }; + const designs = getDesigns(); + designs.push(design); + saveDesigns(designs); + return design; +} + +export function updateDesignJson(id: string, json: any): void { + const designs = getDesigns(); + const idx = designs.findIndex((d) => d.id === id); + if (idx !== -1) { + designs[idx].json = json; + designs[idx].updatedAt = Date.now(); + saveDesigns(designs); + } +} + +export function renameDesign(id: string, name: string): void { + const designs = getDesigns(); + const idx = designs.findIndex((d) => d.id === id); + if (idx !== -1) { + designs[idx].name = name; + designs[idx].updatedAt = Date.now(); + saveDesigns(designs); + } +} + +export function deleteDesign(id: string): void { + const designs = getDesigns().filter((d) => d.id !== id); + saveDesigns(designs); +} + +export function duplicateDesign(id: string, copySuffix: string = '(copie)'): DesignData | undefined { + const original = getDesign(id); + if (!original) return undefined; + return createDesign(`${original.name} ${copySuffix}`, original.json); +} + +export function seedSampleIfEmpty(sampleJson: any): void { + if (getDesigns().length === 0) { + createDesign('Template MacBook Pro', sampleJson); + } +}
- New Design -