Skip to content

Commit

Permalink
feat(sharing): added qr code when note is created (#282)
Browse files Browse the repository at this point in the history
  • Loading branch information
CorentinTh authored Oct 15, 2024
1 parent 53a9d74 commit d2c26cb
Show file tree
Hide file tree
Showing 9 changed files with 203 additions and 3 deletions.
4 changes: 3 additions & 1 deletion packages/app-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
3 changes: 3 additions & 0 deletions packages/app-client/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,6 +30,8 @@ render(
storageManager={localStorageManager}
>
<div class="min-h-screen font-sans text-sm font-400">{props.children}</div>
<Toaster />

</ColorModeProvider>
</NoteContextProvider>
</I18nProvider>
Expand Down
10 changes: 10 additions & 0 deletions packages/app-client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions packages/app-client/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
91 changes: 90 additions & 1 deletion packages/app-client/src/modules/notes/pages/create-note.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,110 @@ 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';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
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 (
<Card class="max-w-500px mx-auto mt-6">
<CardHeader>
<div class="flex items-center gap-4 flex-col-reverse sm:flex-row sm:items-stretch">
<div class="w-full max-w-200px">
<div innerHTML={renderQrCodeSvg(getNoteUrl(), { blackColor: 'white', whiteColor: 'transparent' })} class="dark:block light:hidden" />
<div innerHTML={renderQrCodeSvg(getNoteUrl(), { blackColor: 'black', whiteColor: 'transparent' })} class="light:block dark:hidden" />
</div>

<div class="flex flex-col gap-2 justify-between">
<div>
<div class="text-sm font-semibold">
{t('create.qr-code.title')}
</div>

<div class="text-muted-foreground">
{t('create.qr-code.description')}
</div>
</div>

<div>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="secondary">
{t('create.qr-code.export')}
<div class="i-tabler-chevron-down ml-2 text-lg"></div>
</Button>
</DropdownMenuTrigger>

<DropdownMenuContent>
<DropdownMenuItem
onClick={downloadPngQrCode}
class="cursor-pointer"
>
<div class="i-tabler-photo mr-2 text-lg"></div>
{t('create.qr-code.download-png')}
</DropdownMenuItem>

<DropdownMenuItem
onClick={downloadSvgQrCode}
class="cursor-pointer"
>
<div class="i-tabler-file-type-svg mr-2 text-lg"></div>
{t('create.qr-code.download-svg')}
</DropdownMenuItem>

<DropdownMenuItem onClick={copyQrCodeSvg} class="cursor-pointer">
<div class="i-tabler-copy mr-2 text-lg"></div>
{t('create.qr-code.copy-svg')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</CardHeader>
</Card>
);
};

export const CreateNotePage: Component = () => {
const config = getConfig();
const { t } = useI18n();
Expand Down Expand Up @@ -293,6 +380,8 @@ export const CreateNotePage: Component = () => {
</Button>
</Show>
</div>

<QrCodeCard noteUrl={getNoteUrl()} />
</div>
</Match>
</Switch>
Expand Down
29 changes: 29 additions & 0 deletions packages/app-client/src/modules/shared/files/convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { castError } from '@corentinth/chisels';

export { svgToBase64Png };

function svgToBase64Png({ svg, width, height }: { svg: string; width: number; height: number }): Promise<string> {
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)}`;
});
}
22 changes: 22 additions & 0 deletions packages/app-client/src/modules/shared/files/download.ts
Original file line number Diff line number Diff line change
@@ -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();
}
20 changes: 20 additions & 0 deletions packages/app-client/src/modules/ui/components/sonner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Toaster as Sonner } from 'solid-sonner';

export { toast } from 'solid-sonner';

export function Toaster(props: Parameters<typeof Sonner>[0]) {
return (
<Sonner
class="toaster group"
toastOptions={{
classes: {
toast: 'group toast group-[.toaster]:(bg-background text-foreground border border-border shadow-lg)',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:(bg-primary text-primary-foreground)',
cancelButton: 'group-[.toast]:(bg-muted text-muted-foreground)',
},
}}
{...props}
/>
);
}
17 changes: 16 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit d2c26cb

Please sign in to comment.