diff --git a/client/package.json b/client/package.json index bdf49a50..6c50be41 100644 --- a/client/package.json +++ b/client/package.json @@ -8,6 +8,7 @@ "@tanstack/react-query": "^5.90.20", "date-fns": "^4.1.0", "framer-motion": "^11.11.9", + "html-to-image": "^1.11.13", "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", diff --git a/client/src/components/common/buttons/CopyButton.tsx b/client/src/components/common/buttons/CopyButton.tsx index 1fd5b0bb..79637b8f 100644 --- a/client/src/components/common/buttons/CopyButton.tsx +++ b/client/src/components/common/buttons/CopyButton.tsx @@ -42,7 +42,7 @@ const CopyButton: React.FC = ({ text }) => { return ( + ); +}; + +export default ExportImageButton; diff --git a/client/src/components/editor/export/ExportImageModal.tsx b/client/src/components/editor/export/ExportImageModal.tsx new file mode 100644 index 00000000..94fdb69b --- /dev/null +++ b/client/src/components/editor/export/ExportImageModal.tsx @@ -0,0 +1,180 @@ +import React, { useRef, useState } from 'react'; +import Modal from '../../common/modals/Modal'; +import { CarbonExportPreview } from './CarbonExportPreview'; +import { useTranslation } from 'react-i18next'; +import { toPng, toSvg } from 'html-to-image'; +import { Download, Copy, Share2, Linkedin } from 'lucide-react'; + +export interface ExportImageModalProps { + isOpen: boolean; + onClose: () => void; + code: string; + language: string; +} + +const ExportImageModal: React.FC = ({ + isOpen, + onClose, + code, + language, +}) => { + const { t } = useTranslation('components/common/buttons'); + const previewRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + const [copiedMessage, setCopiedMessage] = useState(''); + + const handleDownloadPng = async () => { + if (!previewRef.current) return; + setIsExporting(true); + try { + const dataUrl = await toPng(previewRef.current, { pixelRatio: 2 }); + const link = document.createElement('a'); + link.download = `carbon-${Date.now()}.png`; + link.href = dataUrl; + link.click(); + } catch (err) { + console.error('Failed to generate PNG', err); + alert(t('exportModal.errorGenerate')); + } finally { + setIsExporting(false); + } + }; + + const handleDownloadSvg = async () => { + if (!previewRef.current) return; + setIsExporting(true); + try { + const dataUrl = await toSvg(previewRef.current); + const link = document.createElement('a'); + link.download = `carbon-${Date.now()}.svg`; + link.href = dataUrl; + link.click(); + } catch (err) { + console.error('Failed to generate SVG', err); + alert(t('exportModal.errorGenerate')); + } finally { + setIsExporting(false); + } + }; + + const handleCopyClipboard = async () => { + if (!previewRef.current) return; + setIsExporting(true); + try { + const dataUrl = await toPng(previewRef.current, { pixelRatio: 2 }); + // Convert base64 to blob + const res = await fetch(dataUrl); + const blob = await res.blob(); + + const item = new ClipboardItem({ 'image/png': blob }); + await navigator.clipboard.write([item]); + + setCopiedMessage(t('exportModal.successCopy')); + setTimeout(() => setCopiedMessage(''), 3000); + } catch (err) { + console.error('Failed to copy to clipboard', err); + alert(t('exportModal.errorCopy')); + } finally { + setIsExporting(false); + } + }; + + const getTwitterShareUrl = () => { + const text = encodeURIComponent('Check out this code snippet!'); + return `https://twitter.com/intent/tweet?text=${text}`; + }; + + const getLinkedInShareUrl = () => { + // LinkedIn doesn't support pre-filling images via URL intent well, + // but users can copy to clipboard and paste in the post intent. + return `https://www.linkedin.com/sharing/share-offsite/`; + }; + + return ( + +
+ + {/* Preview Area */} +
+
+ +
+
+ + {/* Actions Area */} +
+
+ +
+ + + + + + + {copiedMessage && ( + {copiedMessage} + )} +
+ + + +
+
+ +
+
+ ); +}; + +export default ExportImageModal; diff --git a/client/src/i18n/locales/en/components/common/buttons.json b/client/src/i18n/locales/en/components/common/buttons.json index 2aaff2b4..05bd7dce 100644 --- a/client/src/i18n/locales/en/components/common/buttons.json +++ b/client/src/i18n/locales/en/components/common/buttons.json @@ -26,6 +26,21 @@ "nothing": "Nothing to download" } }, + "exportButton": { + "title": "Export as image", + "tooltip": "Export" + }, + "exportModal": { + "copyClipboard": "Copy", + "downloadPng": "Download PNG", + "downloadSvg": "Download SVG", + "errorCopy": "Failed to copy image", + "errorGenerate": "Failed to generate image", + "shareLinkedIn": "Share on LinkedIn", + "shareTwitter": "Share on X", + "successCopy": "Image copied to clipboard!", + "title": "Export code" + }, "fileUploadButton": { "error": { "unknown": "Unknown error" @@ -53,4 +68,4 @@ "rawButton": { "title": "Open raw" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/en/components/snippets/edit.json b/client/src/i18n/locales/en/components/snippets/edit.json index 75647168..72573606 100644 --- a/client/src/i18n/locales/en/components/snippets/edit.json +++ b/client/src/i18n/locales/en/components/snippets/edit.json @@ -60,4 +60,4 @@ "moveDown": "Move down", "moveUp": "Move up" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/en/components/snippets/view/all.json b/client/src/i18n/locales/en/components/snippets/view/all.json index 2a7d0ff0..35edc1b3 100644 --- a/client/src/i18n/locales/en/components/snippets/view/all.json +++ b/client/src/i18n/locales/en/components/snippets/view/all.json @@ -1,5 +1,4 @@ { - "defaultUpdateTime": "Unknown", "fullCodeView": { "dateTimeAgo": "{{dateTime}} ago", "defaultDescription": "No description available" @@ -34,4 +33,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/en/translation.json b/client/src/i18n/locales/en/translation.json index dc06f403..278f9057 100644 --- a/client/src/i18n/locales/en/translation.json +++ b/client/src/i18n/locales/en/translation.json @@ -25,8 +25,8 @@ "locale": { "en": "English", "es": "Spanish", - "ru": "Russian", - "ja": "Japanese" + "ja": "Japanese", + "ru": "Russian" }, "no": "no", "or": "or", diff --git a/client/src/i18n/locales/es/components/common/buttons.json b/client/src/i18n/locales/es/components/common/buttons.json index 965b45bd..1d53a68b 100644 --- a/client/src/i18n/locales/es/components/common/buttons.json +++ b/client/src/i18n/locales/es/components/common/buttons.json @@ -27,6 +27,21 @@ "nothing": "Nada que descargar" } }, + "exportButton": { + "title": "Exportar como imagen", + "tooltip": "Exportar" + }, + "exportModal": { + "copyClipboard": "Copiar", + "downloadPng": "Descargar PNG", + "downloadSvg": "Descargar SVG", + "errorCopy": "Error al copiar la imagen", + "errorGenerate": "Error al generar la imagen", + "shareLinkedIn": "Compartir en LinkedIn", + "shareTwitter": "Compartir en X", + "successCopy": "¡Imagen copiada al portapapeles!", + "title": "Exportar código" + }, "fileUploadButton": { "error": { "unknown": "Error desconocido" @@ -56,4 +71,4 @@ "rawButton": { "title": "Visualizar código original" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/es/components/snippets/edit.json b/client/src/i18n/locales/es/components/snippets/edit.json index 6437f642..65eec77a 100644 --- a/client/src/i18n/locales/es/components/snippets/edit.json +++ b/client/src/i18n/locales/es/components/snippets/edit.json @@ -60,4 +60,4 @@ "moveDown": "Mover abajo", "moveUp": "Mover arriba" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/es/components/snippets/view/all.json b/client/src/i18n/locales/es/components/snippets/view/all.json index 85a8d1d8..f37e15d9 100644 --- a/client/src/i18n/locales/es/components/snippets/view/all.json +++ b/client/src/i18n/locales/es/components/snippets/view/all.json @@ -1,5 +1,4 @@ { - "defaultUpdateTime": "Desconocido", "fullCodeView": { "dateTimeAgo": "Hace {{dateTime}}", "defaultDescription": "Sin descripción disponible" @@ -34,4 +33,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/es/translation.json b/client/src/i18n/locales/es/translation.json index d5a3c0fd..a40e0022 100644 --- a/client/src/i18n/locales/es/translation.json +++ b/client/src/i18n/locales/es/translation.json @@ -25,8 +25,8 @@ "locale": { "en": "Inglés", "es": "Español", - "ru": "Ruso", - "ja": "Japonés" + "ja": "Japonés", + "ru": "Ruso" }, "no": "no", "or": "o", diff --git a/client/src/i18n/locales/ja/components/common/buttons.json b/client/src/i18n/locales/ja/components/common/buttons.json index 7e8f474c..3a761b07 100644 --- a/client/src/i18n/locales/ja/components/common/buttons.json +++ b/client/src/i18n/locales/ja/components/common/buttons.json @@ -26,6 +26,20 @@ "nothing": "ダウンロードするものがありません" } }, + "exportButton": { + "tooltip": "エクスポート" + }, + "exportModal": { + "copyClipboard": "コピー", + "downloadPng": "PNGをダウンロード", + "downloadSvg": "SVGをダウンロード", + "errorCopy": "画像のコピーに失敗しました", + "errorGenerate": "画像の生成に失敗しました", + "shareLinkedIn": "LinkedInで共有", + "shareTwitter": "Xで共有", + "successCopy": "画像がクリップボードにコピーされました!", + "title": "コードをエクスポート" + }, "fileUploadButton": { "error": { "unknown": "不明なエラー" @@ -53,4 +67,4 @@ "rawButton": { "title": "生データを開く" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/ja/components/snippets/edit.json b/client/src/i18n/locales/ja/components/snippets/edit.json index add8a378..f66d187f 100644 --- a/client/src/i18n/locales/ja/components/snippets/edit.json +++ b/client/src/i18n/locales/ja/components/snippets/edit.json @@ -33,6 +33,9 @@ "mustHaveFileNames": "すべてのフラグメントにはファイル名が必要です", "unsavedChanges": "保存されていない変更があります。閉じてもよろしいですか?" }, + "expandSidebar": "サイドバーを展開する", + "fileExtensions": "ファイル拡張子", + "filterFiles": "ファイルを絞り込む", "fragmentEditor": { "action": { "collapse": "折りたたむ", @@ -53,5 +56,8 @@ }, "moveDown": "下に移動", "moveUp": "上に移動" - } -} \ No newline at end of file + }, + "noExtension": "拡張子なし", + "noFilesFound": "ファイルが見つかりません", + "searchFiles": "ファイルを検索する" +} diff --git a/client/src/i18n/locales/ja/components/snippets/view/all.json b/client/src/i18n/locales/ja/components/snippets/view/all.json index 984584a7..b2b3b8f8 100644 --- a/client/src/i18n/locales/ja/components/snippets/view/all.json +++ b/client/src/i18n/locales/ja/components/snippets/view/all.json @@ -1,9 +1,17 @@ { + "collapseSidebar": "サイドバーを折りたたむ", "defaultUpdateTime": "不明", + "expandSidebar": "サイドバーを展開する", + "fileExtensions": "ファイル拡張子", + "files": "ファイル", + "filterFiles": "ファイルを絞り込む", "fullCodeView": { "dateTimeAgo": "{{dateTime}}前", "defaultDescription": "説明なし" }, + "noExtension": "拡張子なし", + "noFilesFound": "ファイルが見つかりません", + "searchFiles": "ファイルを検索する", "snippetModal": { "confirmationModal": { "confirmLabel": { @@ -26,4 +34,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/ja/translation.json b/client/src/i18n/locales/ja/translation.json index 8f523fc8..24734239 100644 --- a/client/src/i18n/locales/ja/translation.json +++ b/client/src/i18n/locales/ja/translation.json @@ -16,6 +16,7 @@ "minimize": "最小化", "restore": "復元", "save": "保存", + "saving": "保存中...", "showPassword": "パスワードを表示", "signIn": "サインイン" }, @@ -24,8 +25,8 @@ "locale": { "en": "英語", "es": "スペイン語", - "ru": "ロシア語", - "ja": "日本語" + "ja": "日本語", + "ru": "ロシア語" }, "no": "いいえ", "or": "または", @@ -49,4 +50,4 @@ "system": "システム" }, "username": "ユーザー名" -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/ru/components/common/buttons.json b/client/src/i18n/locales/ru/components/common/buttons.json index 1cc55ee9..4093f321 100644 --- a/client/src/i18n/locales/ru/components/common/buttons.json +++ b/client/src/i18n/locales/ru/components/common/buttons.json @@ -28,6 +28,20 @@ "nothing": "Нечего скачивать" } }, + "exportButton": { + "tooltip": "Экспорт" + }, + "exportModal": { + "copyClipboard": "Копировать", + "downloadPng": "Скачать PNG", + "downloadSvg": "Скачать SVG", + "errorCopy": "Не удалось скопировать изображение", + "errorGenerate": "Не удалось сгенерировать изображение", + "shareLinkedIn": "Поделиться в LinkedIn", + "shareTwitter": "Поделиться в X", + "successCopy": "Изображение скопировано в буфер обмена!", + "title": "Экспорт кода" + }, "fileUploadButton": { "error": { "unknown": "Неизвестная ошибка" @@ -59,4 +73,4 @@ "rawButton": { "title": "Открыть исходник" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/ru/components/snippets/edit.json b/client/src/i18n/locales/ru/components/snippets/edit.json index d349a89d..693c3bd7 100644 --- a/client/src/i18n/locales/ru/components/snippets/edit.json +++ b/client/src/i18n/locales/ru/components/snippets/edit.json @@ -60,4 +60,4 @@ "moveDown": "Переместить вниз", "moveUp": "Переместить вверх" } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/ru/components/snippets/view/all.json b/client/src/i18n/locales/ru/components/snippets/view/all.json index f4726ee3..3ae08378 100644 --- a/client/src/i18n/locales/ru/components/snippets/view/all.json +++ b/client/src/i18n/locales/ru/components/snippets/view/all.json @@ -1,5 +1,4 @@ { - "defaultUpdateTime": "Неизвестно", "fullCodeView": { "dateTimeAgo": "{{dateTime}} назад", "defaultDescription": "Нет описания" @@ -12,6 +11,7 @@ "filterFiles": "Фильтр файлов", "fileExtensions": "Расширения файлов", "noExtension": "Без расширения", + "defaultUpdateTime": "Неизвестно", "snippetModal": { "confirmationModal": { "confirmLabel": { @@ -34,4 +34,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/ru/translation.json b/client/src/i18n/locales/ru/translation.json index 08c1cb10..bff04a37 100644 --- a/client/src/i18n/locales/ru/translation.json +++ b/client/src/i18n/locales/ru/translation.json @@ -25,8 +25,8 @@ "locale": { "en": "Английский", "es": "Испанский", - "ru": "Русский", - "ja": "Японский" + "ja": "Японский", + "ru": "Русский" }, "no": "нет", "or": "или", diff --git a/package-lock.json b/package-lock.json index ba623bc0..63d20162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,12 @@ "version": "0.1.0", "license": "ISC", "dependencies": { + "@devicon/react": "^0.0.3", "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.90.20", "date-fns": "^4.1.0", "framer-motion": "^11.11.9", + "html-to-image": "^1.11.13", "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", @@ -464,6 +466,12 @@ "@croct/json": "^2.1.0" } }, + "node_modules/@devicon/react": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@devicon/react/-/react-0.0.3.tgz", + "integrity": "sha512-r8Td7z0hyC8Y1PSgZs9NBGDcEosmFjSLWlhbez/8nOR+5HNXFpaFryc7OkKk6F2J+WXEpyxKx81CjXcCW3qO7A==", + "license": "ISC" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -5243,6 +5251,12 @@ "void-elements": "3.1.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",