feat: copy & download qr code as png (#2521)
* copy & download qr code as png * i18n, accessibility * improve error handling
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<ClipboardDocumentIcon />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import ClipboardDocumentIcon from '@heroicons/vue/24/outline/esm/ClipboardDocumentIcon';
|
||||||
|
</script>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user