From d2c26cb50a72c14130bba831600d0fe41762a3e2 Mon Sep 17 00:00:00 2001 From: Corentin THOMASSET Date: Tue, 15 Oct 2024 23:56:25 +0200 Subject: [PATCH] feat(sharing): added qr code when note is created (#282) --- packages/app-client/package.json | 4 +- packages/app-client/src/index.tsx | 3 + packages/app-client/src/locales/en.json | 10 ++ packages/app-client/src/locales/fr.json | 10 ++ .../modules/notes/pages/create-note.page.tsx | 91 ++++++++++++++++++- .../src/modules/shared/files/convert.ts | 29 ++++++ .../src/modules/shared/files/download.ts | 22 +++++ .../src/modules/ui/components/sonner.tsx | 20 ++++ pnpm-lock.yaml | 17 +++- 9 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 packages/app-client/src/modules/shared/files/convert.ts create mode 100644 packages/app-client/src/modules/shared/files/download.ts create mode 100644 packages/app-client/src/modules/ui/components/sonner.tsx diff --git a/packages/app-client/package.json b/packages/app-client/package.json index e5858f59..57f6fbb3 100644 --- a/packages/app-client/package.json +++ b/packages/app-client/package.json @@ -40,8 +40,10 @@ "jszip": "^3.10.1", "lodash-es": "^4.17.21", "solid-js": "^1.8.11", + "solid-sonner": "^0.2.8", "tailwind-merge": "^2.5.2", - "unocss-preset-animations": "^1.1.0" + "unocss-preset-animations": "^1.1.0", + "uqr": "^0.1.2" }, "devDependencies": { "@antfu/eslint-config": "catalog:", diff --git a/packages/app-client/src/index.tsx b/packages/app-client/src/index.tsx index 233c530e..d391cd83 100644 --- a/packages/app-client/src/index.tsx +++ b/packages/app-client/src/index.tsx @@ -5,6 +5,7 @@ import { Router } from '@solidjs/router'; import { render, Suspense } from 'solid-js/web'; import { I18nProvider } from './modules/i18n/i18n.provider'; import { NoteContextProvider } from './modules/notes/notes.context'; +import { Toaster } from './modules/ui/components/sonner'; import { routes } from './routes'; import '@unocss/reset/tailwind.css'; import 'virtual:uno.css'; @@ -29,6 +30,8 @@ render( storageManager={localStorageManager} >
{props.children}
+ + diff --git a/packages/app-client/src/locales/en.json b/packages/app-client/src/locales/en.json index 524cb481..7d8562b2 100644 --- a/packages/app-client/src/locales/en.json +++ b/packages/app-client/src/locales/en.json @@ -92,6 +92,16 @@ "with-deletion": "The note will be deleted after reading.", "copy-link": "Copy link", "copy-success": "Link copied" + }, + "qr-code": { + "title": "Share this note on mobile", + "description": "You can scan this QR code to view the note on your mobile device. You can also export the QR code image to share it with others.", + "export": "Export QR code", + "download-svg": "Download as SVG", + "download-png": "Download as PNG", + "download-success": "QR code downloaded", + "copy-svg": "Copy SVG code", + "copy-success": "SVG copied to clipboard" } }, "view": { diff --git a/packages/app-client/src/locales/fr.json b/packages/app-client/src/locales/fr.json index 7b762076..ba6bdc5f 100644 --- a/packages/app-client/src/locales/fr.json +++ b/packages/app-client/src/locales/fr.json @@ -92,6 +92,16 @@ "with-deletion": "La note sera supprimée après lecture.", "copy-link": "Copier le lien", "copy-success": "Lien copié" + }, + "qr-code": { + "title": "Partager cette note sur mobile", + "description": "Scannez ce QR code pour ouvrir la note sur votre appareil mobile. Vous pouvez également exporter le QR code pour le partager.", + "export": "Exporter le QR code", + "download-svg": "Télécharger au format SVG", + "download-png": "Télécharger au format PNG", + "download-success": "Téléchargement réussi", + "copy-svg": "Copier le code du SVG", + "copy-success": "SVG copié dans le presse-papiers" } }, "view": { diff --git a/packages/app-client/src/modules/notes/pages/create-note.page.tsx b/packages/app-client/src/modules/notes/pages/create-note.page.tsx index 53ef2a68..abfa8ab5 100644 --- a/packages/app-client/src/modules/notes/pages/create-note.page.tsx +++ b/packages/app-client/src/modules/notes/pages/create-note.page.tsx @@ -2,11 +2,16 @@ import { authStore } from '@/modules/auth/auth.store'; import { getConfig } from '@/modules/config/config.provider'; import { getFileIcon } from '@/modules/files/files.models'; import { useI18n } from '@/modules/i18n/i18n.provider'; +import { svgToBase64Png } from '@/modules/shared/files/convert'; +import { downloadBase64File, downloadSvgFile } from '@/modules/shared/files/download'; import { isHttpErrorWithCode, isHttpErrorWithStatusCode, isRateLimitError } from '@/modules/shared/http/http-errors'; import { cn } from '@/modules/shared/style/cn'; -import { CopyButton } from '@/modules/shared/utils/copy'; +import { CopyButton, useCopy } from '@/modules/shared/utils/copy'; import { Alert, AlertDescription } from '@/modules/ui/components/alert'; import { Button } from '@/modules/ui/components/button'; +import { Card, CardHeader } from '@/modules/ui/components/card'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu'; +import { toast } from '@/modules/ui/components/sonner'; import { SwitchControl, SwitchLabel, SwitchThumb, Switch as SwitchUiComponent } from '@/modules/ui/components/switch'; import { Tabs, TabsIndicator, TabsList, TabsTrigger } from '@/modules/ui/components/tabs'; import { TextArea } from '@/modules/ui/components/textarea'; @@ -14,11 +19,93 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component import { safely } from '@corentinth/chisels'; import { useNavigate } from '@solidjs/router'; import { type Component, createSignal, Match, onCleanup, onMount, Show, Switch } from 'solid-js'; +import { renderSVG as renderQrCodeSvg } from 'uqr'; import { FileUploaderButton } from '../components/file-uploader'; import { NotePasswordField } from '../components/note-password-field'; import { useNoteContext } from '../notes.context'; import { encryptAndCreateNote } from '../notes.usecases'; +const QrCodeCard: Component<{ noteUrl: string }> = (props) => { + const getNoteUrl = () => props.noteUrl; + const { t } = useI18n(); + const { copy } = useCopy(); + + const downloadSvgQrCode = () => { + downloadSvgFile({ svg: renderQrCodeSvg(getNoteUrl()), fileName: 'note-qr-code.svg' }); + toast.success(t('create.qr-code.download-success')); + }; + + const downloadPngQrCode = async () => { + const base64 = await svgToBase64Png({ svg: renderQrCodeSvg(getNoteUrl()), width: 512, height: 512 }); + downloadBase64File({ base64, fileName: 'note-qr-code.png' }); + toast.success(t('create.qr-code.download-success')); + }; + + const copyQrCodeSvg = () => { + copy({ text: renderQrCodeSvg(getNoteUrl()) }); + toast.success(t('create.qr-code.copy-success')); + }; + + return ( + + +
+
+
+
+
+ +
+
+
+ {t('create.qr-code.title')} +
+ +
+ {t('create.qr-code.description')} +
+
+ +
+ + + + + + + +
+ {t('create.qr-code.download-png')} +
+ + +
+ {t('create.qr-code.download-svg')} +
+ + +
+ {t('create.qr-code.copy-svg')} +
+
+
+
+
+
+ + + ); +}; + export const CreateNotePage: Component = () => { const config = getConfig(); const { t } = useI18n(); @@ -293,6 +380,8 @@ export const CreateNotePage: Component = () => {
+ +
diff --git a/packages/app-client/src/modules/shared/files/convert.ts b/packages/app-client/src/modules/shared/files/convert.ts new file mode 100644 index 00000000..5e9fadc7 --- /dev/null +++ b/packages/app-client/src/modules/shared/files/convert.ts @@ -0,0 +1,29 @@ +import { castError } from '@corentinth/chisels'; + +export { svgToBase64Png }; + +function svgToBase64Png({ svg, width, height }: { svg: string; width: number; height: number }): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.width = width; + img.height = height; + + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Could not get 2d context')); + return; + } + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + resolve(canvas.toDataURL('image/png')); + }; + img.onerror = (err) => { + reject(castError(err)); + }; + img.src = `data:image/svg+xml;base64,${btoa(svg)}`; + }); +} diff --git a/packages/app-client/src/modules/shared/files/download.ts b/packages/app-client/src/modules/shared/files/download.ts new file mode 100644 index 00000000..5bd00c5b --- /dev/null +++ b/packages/app-client/src/modules/shared/files/download.ts @@ -0,0 +1,22 @@ +export { downloadBase64File, downloadFile, downloadSvgFile }; + +function downloadSvgFile({ svg, fileName }: { svg: string; fileName: string }) { + const blob = new Blob([svg], { type: 'image/svg+xml' }); + downloadFile({ blob, fileName }); +} + +function downloadFile({ blob, fileName }: { blob: Blob; fileName: string }) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +} + +function downloadBase64File({ base64, fileName }: { base64: string; fileName: string }) { + const a = document.createElement('a'); + a.href = base64; + a.download = fileName; + a.click(); +} diff --git a/packages/app-client/src/modules/ui/components/sonner.tsx b/packages/app-client/src/modules/ui/components/sonner.tsx new file mode 100644 index 00000000..8ed57c5d --- /dev/null +++ b/packages/app-client/src/modules/ui/components/sonner.tsx @@ -0,0 +1,20 @@ +import { Toaster as Sonner } from 'solid-sonner'; + +export { toast } from 'solid-sonner'; + +export function Toaster(props: Parameters[0]) { + return ( + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9c6aeb9..4543cf25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,12 +71,18 @@ importers: solid-js: specifier: ^1.8.11 version: 1.9.2 + solid-sonner: + specifier: ^0.2.8 + version: 0.2.8(solid-js@1.9.2) tailwind-merge: specifier: ^2.5.2 version: 2.5.4 unocss-preset-animations: specifier: ^1.1.0 version: 1.1.0(@unocss/preset-wind@0.63.4)(unocss@0.63.4(postcss@8.4.47)(rollup@4.24.0)(vite@5.4.9(@types/node@22.7.4)(less@4.2.0)(terser@5.32.0))) + uqr: + specifier: ^0.1.2 + version: 0.1.2 devDependencies: '@antfu/eslint-config': specifier: 'catalog:' @@ -2740,7 +2746,7 @@ packages: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} conf@13.0.1: resolution: {integrity: sha512-l9Uwc9eOnz39oADzGO2cSBDi7siv8lwO+31ocQ2nOJijnDiW3pxqm9VV10DPYUO28wW83DjABoUqY1nfHRR2hQ==} @@ -4777,6 +4783,11 @@ packages: peerDependencies: solid-js: ^1.3 + solid-sonner@0.2.8: + resolution: {integrity: sha512-EQ2EIznvHHpAmkYh2CTu0AdCgmPJRJWLGFRWygE8j+vMEfvIV2wotHU5qgWzqzVTG1SODGsay2Lwq6ENWx/rPA==} + peerDependencies: + solid-js: ^1.6.0 + sonic-boom@4.0.1: resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==} @@ -10176,6 +10187,10 @@ snapshots: transitivePeerDependencies: - supports-color + solid-sonner@0.2.8(solid-js@1.9.2): + dependencies: + solid-js: 1.9.2 + sonic-boom@4.0.1: dependencies: atomic-sleep: 1.0.0