Browse Source

feat: copy & download qr code as png (#2521)

* copy & download qr code as png

* i18n, accessibility

* improve error handling
pull/2522/head
Bernd Storath 3 months ago
committed by GitHub
parent
commit
bc4dfd03df
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      src/app/app.css
  2. 99
      src/app/components/Clients/QRCodeDialog.vue
  3. 4
      src/app/components/Header/ChartToggle.vue
  4. 7
      src/app/components/Icons/Copy.vue
  5. 7
      src/i18n/locales/en.json
  6. 1
      src/nuxt.config.ts

7
src/app/app.css

@ -0,0 +1,7 @@
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}

99
src/app/components/Clients/QRCodeDialog.vue

@ -5,10 +5,24 @@
</template> </template>
<template #description> <template #description>
<div class="bg-white"> <div class="bg-white">
<img :src="qrCode" /> <img ref="img" :src="qrCode" />
</div> </div>
</template> </template>
<template #actions> <template #actions>
<BaseSecondaryButton
class="flex items-center gap-2"
:title="$t('client.copyPng')"
@click="copyPng"
>
<IconsCopy class="size-5" /> PNG
</BaseSecondaryButton>
<BaseSecondaryButton
class="flex items-center gap-2"
:title="$t('client.downloadPng')"
@click="downloadPng"
>
<IconsDownload class="size-5" /> PNG
</BaseSecondaryButton>
<DialogClose as-child> <DialogClose as-child>
<BaseSecondaryButton>{{ $t('dialog.cancel') }}</BaseSecondaryButton> <BaseSecondaryButton>{{ $t('dialog.cancel') }}</BaseSecondaryButton>
</DialogClose> </DialogClose>
@ -18,4 +32,87 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ qrCode: string }>(); defineProps<{ qrCode: string }>();
const toast = useToast();
const img = useTemplateRef('img');
async function svgToPng() {
if (!img.value || !img.value.complete || img.value.naturalWidth === 0) {
throw new Error('image is not loaded');
}
const width = 1000;
const height = 1000;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('was not able to create 2d context');
}
ctx.drawImage(img.value!, 0, 0, width, height);
return new Promise<Blob>((res, rej) => {
canvas.toBlob((blob) => {
if (!blob) {
return rej(new Error('was not able to create blob'));
}
return res(blob);
}, 'image/png');
});
}
async function downloadPng() {
try {
const blob = await svgToPng();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'client-config.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error('failed to download png', e);
toast.showToast({
type: 'error',
message: $t('toast.unknown'),
});
}
}
async function copyPng() {
const blob = await svgToPng().catch((e) => {
console.error('failed to convert svg to png', e);
toast.showToast({
type: 'error',
message: $t('toast.unknown'),
});
});
if (!blob) {
return;
}
try {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
toast.showToast({
type: 'success',
message: $t('copy.copied'),
});
} catch (e) {
console.error('failed to copy png', e);
toast.showToast({
type: 'error',
message: $t('copy.failed'),
});
}
}
</script> </script>

4
src/app/components/Header/ChartToggle.vue

@ -1,12 +1,12 @@
<template> <template>
<Toggle <Toggle
:pressed="globalStore.uiShowCharts" :pressed="globalStore.uiShowCharts"
class="group inline-flex h-8 w-8 cursor-pointer items-center justify-center whitespace-nowrap rounded-full bg-gray-200 transition hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600" class="group flex h-8 w-8 items-center justify-center rounded-full bg-gray-200 transition hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600"
:title="$t('layout.toggleCharts')" :title="$t('layout.toggleCharts')"
@update:pressed="globalStore.toggleCharts" @update:pressed="globalStore.toggleCharts"
> >
<IconsChart <IconsChart
class="h-5 w-5 fill-gray-400 transition group-data-[state=on]:fill-gray-600 dark:fill-neutral-600 dark:group-data-[state=on]:fill-neutral-400" class="h-5 w-5 transition group-data-[state=on]:fill-gray-600 dark:text-neutral-400 dark:group-data-[state=on]:fill-gray-300"
/> />
</Toggle> </Toggle>
</template> </template>

7
src/app/components/Icons/Copy.vue

@ -0,0 +1,7 @@
<template>
<ClipboardDocumentIcon />
</template>
<script lang="ts" setup>
import ClipboardDocumentIcon from '@heroicons/vue/24/outline/esm/ClipboardDocumentIcon';
</script>

7
src/i18n/locales/en.json

@ -122,7 +122,9 @@
"config": "Configuration", "config": "Configuration",
"viewConfig": "View Configuration", "viewConfig": "View Configuration",
"firewallIps": "Firewall Allowed IPs", "firewallIps": "Firewall Allowed IPs",
"firewallIpsDesc": "Destination IPs/CIDRs this client can access (server-side enforcement). Leave empty to use Allowed IPs. Supports optional port and protocol filtering. See docs for syntax." "firewallIpsDesc": "Destination IPs/CIDRs this client can access (server-side enforcement). Leave empty to use Allowed IPs. Supports optional port and protocol filtering. See docs for syntax.",
"downloadPng": "Download PNG",
"copyPng": "Copy PNG"
}, },
"dialog": { "dialog": {
"change": "Change", "change": "Change",
@ -132,7 +134,8 @@
"toast": { "toast": {
"success": "Success", "success": "Success",
"saved": "Saved", "saved": "Saved",
"error": "Error" "error": "Error",
"unknown": "Unknown error. See console for more details"
}, },
"form": { "form": {
"actions": "Actions", "actions": "Actions",

1
src/nuxt.config.ts

@ -23,6 +23,7 @@ export default defineNuxtConfig({
classSuffix: '', classSuffix: '',
cookieName: 'theme', cookieName: 'theme',
}, },
css: ['~/app.css'],
i18n: { i18n: {
// https://i18n.nuxtjs.org/docs/guide/server-side-translations // https://i18n.nuxtjs.org/docs/guide/server-side-translations
experimental: { experimental: {

Loading…
Cancel
Save