mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-23 14:34:19 +02:00
feat(frontend): 自分のプロフィールページの二次元コード(QRコード)を表示し、他の人のコードを読み取りするページを追加 (#16456)
* wip (qr.show.vue) * added to navbar * qr.show.vue * fix * added to navbar * fix size * 🎨 * 🎨 * fix div warn * fix * use * 0.25 * fix?? * fix lint * clean up * ??? * ? * fix * 🎨 * 🎨 * refactor * 🎨 * 🎨 * :ar:t * 🎨 * iphone flip * no lazy import * 🎨 * 🎨 * 🎨 * ユーザー全部flipでいいや * ✌️ * fix * fix * fix lint * 🎨 * fix type * fix: local user profile url cannot be resolved with ap/show * fix: local user url with hostname could not be resolved * chore: use common utility for checking self host * wip * 🎨 * 🎨 * fix imports * fix * fix * fix * 🎨 * fix... * set spacer-w * ✌️ * 全画面でQRを読むように * fix * 🎨 * modify navbar.ts * start/stop on vue activation * display raw content read from qr * 端末のQRをスキャンするボタンを追加 * chore * やっぱりmfmを先に表示する * 🎨 * fix 18n * QRの内容は/users/:userIdにする * add spdx * use cqh * `defineProps` is a compiler macro and no longer needs to be imported. * use MkUserName * 🎨 * 🎨 * refactor * clean up * refactor * 🎨 * Update qr.show.vue * Misskeyロゴにdrop-shadowを追加 * clean up: do not use empty css * fix os.select usage * Update qr.vue * Update qr.show.vue * Update qr.show.vue * Update get-user-menu.ts * ✌️ * Update show.ts * Update ja-JP.yml * watermark * Update CHANGELOG.md * Update qr.read.vue * Update qr.read.vue * wip * Update MkWatermarkEditorDialog.Layer.vue --------- Co-authored-by: anatawa12 <anatawa12@icloud.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
|
||||
<MkPolkadots v-if="tab === 'home'" accented/>
|
||||
<MkPolkadots v-if="tab === 'home'" accented :height="200" style="margin-bottom: -200px;"/>
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<XHome v-if="tab === 'home'"/>
|
||||
<XInvitations v-else-if="tab === 'invitations'"/>
|
||||
|
||||
54
packages/frontend/src/pages/qr.read.raw-viewer.vue
Normal file
54
packages/frontend/src/pages/qr.read.raw-viewer.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkFolder defaultOpen :withSpacer="false">
|
||||
<template #label>{{ data.split('\n')[0] }}</template>
|
||||
<template #header>
|
||||
<MkTabs
|
||||
v-model:tab="tab"
|
||||
:tabs="[
|
||||
{
|
||||
key: 'mfm',
|
||||
title: i18n.ts._qr.mfm,
|
||||
icon: 'ti ti-align-left',
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
title: i18n.ts._qr.raw,
|
||||
icon: 'ti ti-code',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-show="tab === 'mfm'" class="_spacer _gaps">
|
||||
<Mfm :text="data" :nyaize="false"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false"/>
|
||||
</div>
|
||||
<div v-show="tab === 'raw'" class="_spacer" style="--MI_SPACER-min: 10px; --MI_SPACER-max: 16px;">
|
||||
<MkCode :code="data" lang="text"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkTabs from '@/components/MkTabs.vue';
|
||||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
data: string;
|
||||
}>();
|
||||
|
||||
const parsed = computed(() => mfm.parse(props.data));
|
||||
const urls = computed(() => extractUrlFromMfm(parsed.value));
|
||||
const tab = ref<'mfm' | 'raw'>('mfm');
|
||||
</script>
|
||||
397
packages/frontend/src/pages/qr.read.vue
Normal file
397
packages/frontend/src/pages/qr.read.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="$style.root"
|
||||
:style="{
|
||||
'--MI-QrReadViewHeight': 'calc(100cqh - var(--MI-stickyTop, 0px) - var(--MI-stickyBottom, 0px))',
|
||||
'--MI-QrReadVideoHeight': 'min(calc(var(--MI-QrReadViewHeight) * 0.3), 512px)',
|
||||
}"
|
||||
>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<div :class="$style.view">
|
||||
<video ref="videoEl" :class="$style.video" autoplay muted playsinline></video>
|
||||
<div ref="overlayEl" :class="$style.overlay"></div>
|
||||
<div :class="$style.controls">
|
||||
<MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton>
|
||||
|
||||
<MkButton v-if="qrStarted" v-tooltip="i18n.ts._qr.stopQr" iconOnly @click="stopQr"><i class="ti ti-player-play"></i></MkButton>
|
||||
<MkButton v-else v-tooltip="i18n.ts._qr.startQr" iconOnly danger @click="startQr"><i class="ti ti-player-pause"></i></MkButton>
|
||||
|
||||
<MkButton v-tooltip="i18n.ts._qr.chooseCamera" iconOnly @click="chooseCamera"><i class="ti ti-camera-rotate"></i></MkButton>
|
||||
|
||||
<MkButton v-if="!flashCanToggle" v-tooltip="i18n.ts._qr.cannotToggleFlash" iconOnly disabled><i class="ti ti-bolt"></i></MkButton>
|
||||
<MkButton v-else-if="!flash" v-tooltip="i18n.ts._qr.turnOnFlash" iconOnly @click="toggleFlash(true)"><i class="ti ti-bolt-off"></i></MkButton>
|
||||
<MkButton v-else v-tooltip="i18n.ts._qr.turnOffFlash" iconOnly @click="toggleFlash(false)"><i class="ti ti-bolt-filled"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
:class="['_spacer', $style.contents]"
|
||||
:style="{
|
||||
'--MI_SPACER-w': '800px'
|
||||
}"
|
||||
>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkTab v-model="tab" :class="$style.tab">
|
||||
<option value="users">{{ i18n.ts.users }}</option>
|
||||
<option value="notes">{{ i18n.ts.notes }}</option>
|
||||
<option value="all">{{ i18n.ts.all }}</option>
|
||||
</MkTab>
|
||||
</template>
|
||||
<div v-if="tab === 'users'" :class="[$style.users, '_margin']" style="padding-bottom: var(--MI-margin);">
|
||||
<MkUserInfo v-for="user in users" :key="user.id" :user="user"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'notes'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);">
|
||||
<MkNote v-for="note in notes" :key="note.id" :note="note" :class="$style.note"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'all'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);">
|
||||
<MkQrReadRawViewer v-for="result in Array.from(results).reverse()" :key="result" :data="result"/>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import QrScanner from 'qr-scanner';
|
||||
import { onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { getScrollContainer } from '@@/js/scroll.js';
|
||||
import type { ApShowResponse } from 'misskey-js/entities.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkUserInfo from '@/components/MkUserInfo.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkTab from '@/components/MkTab.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkQrReadRawViewer from '@/pages/qr.read.raw-viewer.vue';
|
||||
|
||||
const LIST_RERENDER_INTERVAL = 1500;
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const videoEl = useTemplateRef('videoEl');
|
||||
const overlayEl = useTemplateRef('overlayEl');
|
||||
|
||||
const scannerInstance = shallowRef<QrScanner | null>(null);
|
||||
|
||||
const tab = ref<'users' | 'notes' | 'all'>('users');
|
||||
|
||||
// higher is recent
|
||||
const results = ref(new Set<string>());
|
||||
// lower is recent
|
||||
const uris = ref<string[]>([]);
|
||||
const sources = new Map<string, ApShowResponse | null>();
|
||||
const users = ref<(misskey.entities.UserDetailed)[]>([]);
|
||||
const usersCount = ref(0);
|
||||
const notes = ref<misskey.entities.Note[]>([]);
|
||||
const notesCount = ref(0);
|
||||
|
||||
const timer = ref<number | null>(null);
|
||||
|
||||
function updateLists() {
|
||||
const responses = uris.value.map(uri => sources.get(uri)).filter((r): r is ApShowResponse => !!r);
|
||||
users.value = responses.filter(r => r.type === 'User').map(r => r.object).filter((u): u is misskey.entities.UserDetailed => !!u);
|
||||
usersCount.value = users.value.length;
|
||||
notes.value = responses.filter(r => r.type === 'Note').map(r => r.object).filter((n): n is misskey.entities.Note => !!n);
|
||||
notesCount.value = notes.value.length;
|
||||
updateRequired.value = false;
|
||||
}
|
||||
|
||||
const updateRequired = ref(false);
|
||||
|
||||
watch(uris, () => {
|
||||
if (timer.value) {
|
||||
updateRequired.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
updateLists();
|
||||
|
||||
timer.value = window.setTimeout(() => {
|
||||
timer.value = null;
|
||||
if (updateRequired.value) {
|
||||
updateLists();
|
||||
}
|
||||
}, LIST_RERENDER_INTERVAL) as number;
|
||||
});
|
||||
|
||||
watch(tab, () => {
|
||||
if (timer.value) {
|
||||
window.clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
updateLists();
|
||||
});
|
||||
|
||||
async function processResult(result: QrScanner.ScanResult) {
|
||||
if (!result) return;
|
||||
const trimmed = result.data.trim();
|
||||
|
||||
if (!trimmed) return;
|
||||
|
||||
const haveExisted = results.value.has(trimmed);
|
||||
results.value.add(trimmed);
|
||||
|
||||
try {
|
||||
new URL(trimmed);
|
||||
} catch {
|
||||
if (!haveExisted) {
|
||||
tab.value = 'all';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (uris.value[0] !== trimmed) {
|
||||
// 並べ替え
|
||||
uris.value = [trimmed, ...uris.value.slice(0, 29).filter(u => u !== trimmed)];
|
||||
}
|
||||
|
||||
if (sources.has(trimmed)) return;
|
||||
// Start fetching user info
|
||||
sources.set(trimmed, null);
|
||||
|
||||
await misskeyApi('ap/show', { uri: trimmed })
|
||||
.then(data => {
|
||||
if (data.type === 'User') {
|
||||
sources.set(trimmed, data);
|
||||
tab.value = 'users';
|
||||
} else if (data.type === 'Note') {
|
||||
sources.set(trimmed, data);
|
||||
tab.value = 'notes';
|
||||
}
|
||||
updateLists();
|
||||
})
|
||||
.catch(err => {
|
||||
tab.value = 'all';
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
const qrStarted = ref(true);
|
||||
const flashCanToggle = ref(false);
|
||||
const flash = ref(false);
|
||||
|
||||
async function upload() {
|
||||
os.chooseFileFromPc({ multiple: true }).then(files => {
|
||||
if (files.length === 0) return;
|
||||
for (const file of files) {
|
||||
QrScanner.scanImage(file, { returnDetailedScanResult: true })
|
||||
.then(result => {
|
||||
processResult(result);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.toString().includes('No QR code found')) {
|
||||
os.alert({
|
||||
type: 'info',
|
||||
text: i18n.ts._qr.noQrCodeFound,
|
||||
});
|
||||
} else {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: err.toString(),
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseCamera() {
|
||||
if (!scannerInstance.value) return;
|
||||
const cameras = await QrScanner.listCameras(true);
|
||||
if (cameras.length === 0) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const select = await os.select({
|
||||
title: i18n.ts._qr.chooseCamera,
|
||||
items: cameras.map(camera => ({
|
||||
label: camera.label,
|
||||
value: camera.id,
|
||||
})),
|
||||
});
|
||||
if (select.canceled) return;
|
||||
if (select.result == null) return;
|
||||
|
||||
await scannerInstance.value.setCamera(select.result);
|
||||
flashCanToggle.value = await scannerInstance.value.hasFlash();
|
||||
flash.value = scannerInstance.value.isFlashOn();
|
||||
}
|
||||
|
||||
async function toggleFlash(to = false) {
|
||||
if (!scannerInstance.value) return;
|
||||
|
||||
flash.value = to;
|
||||
if (flash.value) {
|
||||
await scannerInstance.value.turnFlashOn();
|
||||
} else {
|
||||
await scannerInstance.value.turnFlashOff();
|
||||
}
|
||||
}
|
||||
|
||||
async function startQr() {
|
||||
if (!scannerInstance.value) return;
|
||||
await scannerInstance.value.start();
|
||||
qrStarted.value = true;
|
||||
}
|
||||
|
||||
function stopQr() {
|
||||
if (!scannerInstance.value) return;
|
||||
scannerInstance.value.stop();
|
||||
qrStarted.value = false;
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
startQr;
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
stopQr;
|
||||
});
|
||||
|
||||
const alertLock = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (!videoEl.value || !overlayEl.value) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scannerInstance.value = new QrScanner(
|
||||
videoEl.value,
|
||||
processResult,
|
||||
{
|
||||
highlightScanRegion: true,
|
||||
highlightCodeOutline: true,
|
||||
overlay: overlayEl.value,
|
||||
calculateScanRegion(video: HTMLVideoElement): QrScanner.ScanRegion {
|
||||
const aspectRatio = video.videoWidth / video.videoHeight;
|
||||
const SHORT_SIDE_SIZE_DOWNSCALED = 360;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
downScaledWidth: aspectRatio > 1 ? Math.round(SHORT_SIDE_SIZE_DOWNSCALED * aspectRatio) : SHORT_SIDE_SIZE_DOWNSCALED,
|
||||
downScaledHeight: aspectRatio > 1 ? SHORT_SIDE_SIZE_DOWNSCALED : Math.round(SHORT_SIDE_SIZE_DOWNSCALED / aspectRatio),
|
||||
};
|
||||
},
|
||||
onDecodeError(err) {
|
||||
if (err.toString().includes('No QR code found')) return;
|
||||
if (alertLock.value) return;
|
||||
alertLock.value = true;
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: err.toString(),
|
||||
}).finally(() => {
|
||||
alertLock.value = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
scannerInstance.value.start()
|
||||
.then(async () => {
|
||||
qrStarted.value = true;
|
||||
if (!scannerInstance.value) return;
|
||||
flashCanToggle.value = await scannerInstance.value.hasFlash();
|
||||
flash.value = scannerInstance.value.isFlashOn();
|
||||
})
|
||||
.catch(err => {
|
||||
qrStarted.value = false;
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: err.toString(),
|
||||
});
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
window.clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
|
||||
scannerInstance.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.view {
|
||||
position: sticky;
|
||||
top: var(--MI-stickyTop, 0);
|
||||
z-index: 1;
|
||||
background: var(--MI_THEME-bg);
|
||||
background-size: 16px 16px;
|
||||
width: 100%;
|
||||
height: var(--MI-QrReadVideoHeight);
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
html[data-color-scheme=dark] .view {
|
||||
--c: rgb(255 255 255 / 2%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%);
|
||||
}
|
||||
|
||||
html[data-color-scheme=light] .view {
|
||||
--c: rgb(0 0 0 / 2%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%);
|
||||
}
|
||||
|
||||
.contents {
|
||||
padding-top: calc(var(--MI-margin) / 2);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: calc(var(--MI-margin) / 2) 0;
|
||||
background: var(--MI_THEME-bg);
|
||||
}
|
||||
|
||||
.users {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-gap: var(--MI-margin);
|
||||
}
|
||||
|
||||
.note {
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: var(--MI-radius);
|
||||
}
|
||||
</style>
|
||||
234
packages/frontend/src/pages/qr.show.vue
Normal file
234
packages/frontend/src/pages/qr.show.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.content]">
|
||||
<div
|
||||
ref="qrCodeEl" v-flip :style="{
|
||||
'cursor': canShare ? 'pointer' : 'default',
|
||||
}"
|
||||
:class="$style.qr" @click="share"
|
||||
></div>
|
||||
<div v-flip :class="$style.user">
|
||||
<MkAvatar :class="$style.avatar" :user="$i" :indicator="false"/>
|
||||
<div>
|
||||
<div :class="$style.name"><MkCondensedLine :minScale="2 / 3"><MkUserName :user="$i" :nowrap="true"/></MkCondensedLine></div>
|
||||
<div><MkCondensedLine :minScale="2 / 3">{{ acct }}</MkCondensedLine></div>
|
||||
</div>
|
||||
</div>
|
||||
<img v-if="deviceMotionPermissionNeeded" v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo" @click="requestDeviceMotion"/>
|
||||
<img v-else v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import tinycolor from 'tinycolor2';
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
import { computed, ref, shallowRef, watch, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { url, host } from '@@/js/config.js';
|
||||
import type { Directive } from 'vue';
|
||||
import { instance } from '@/instance.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { userPage, userName } from '@/filters/user.js';
|
||||
import misskeysvg from '/client-assets/misskey.svg';
|
||||
import { getStaticImageUrl } from '@/utility/media-proxy.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const acct = computed(() => `@${$i.username}@${host}`);
|
||||
const userProfileUrl = computed(() => userPage($i, undefined, true));
|
||||
const shareData = computed(() => ({
|
||||
title: i18n.tsx._qr.shareTitle({ name: userName($i), acct: acct.value }),
|
||||
text: i18n.ts._qr.shareText,
|
||||
url: userProfileUrl.value,
|
||||
}));
|
||||
const canShare = computed(() => navigator.canShare && navigator.canShare(shareData.value));
|
||||
|
||||
const qrCodeEl = useTemplateRef('qrCodeEl');
|
||||
|
||||
const qrColor = computed(() => tinycolor(instance.themeColor ?? '#86b300'));
|
||||
const qrHsl = computed(() => qrColor.value.toHsl());
|
||||
|
||||
function share() {
|
||||
if (!canShare.value) return;
|
||||
return navigator.share(shareData.value);
|
||||
}
|
||||
|
||||
const qrCodeInstance = new QRCodeStyling({
|
||||
width: 600,
|
||||
height: 600,
|
||||
margin: 42,
|
||||
type: 'canvas',
|
||||
data: `${url}/users/${$i.id}`,
|
||||
image: instance.iconUrl ? getStaticImageUrl(instance.iconUrl) : '/favicon.ico',
|
||||
qrOptions: {
|
||||
typeNumber: 0,
|
||||
mode: 'Byte',
|
||||
errorCorrectionLevel: 'H',
|
||||
},
|
||||
imageOptions: {
|
||||
hideBackgroundDots: true,
|
||||
imageSize: 0.3,
|
||||
margin: 16,
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
dotsOptions: {
|
||||
type: 'dots',
|
||||
color: tinycolor(`hsl(${qrHsl.value.h}, 100, 18)`).toRgbString(),
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: 'dot',
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: 'extra-rounded',
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: tinycolor(`hsl(${qrHsl.value.h}, 100, 97)`).toRgbString(),
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (qrCodeEl.value != null) {
|
||||
qrCodeInstance.append(qrCodeEl.value);
|
||||
}
|
||||
});
|
||||
|
||||
//#region flip
|
||||
const THRESHOLD = -3;
|
||||
// @ts-expect-error TS(2339)
|
||||
const deviceMotionPermissionNeeded = window.DeviceMotionEvent && typeof window.DeviceMotionEvent.requestPermission === 'function';
|
||||
const flipEls: Set<Element> = new Set();
|
||||
const flip = ref(false);
|
||||
|
||||
function handleOrientationChange(event: DeviceOrientationEvent) {
|
||||
const isUpsideDown = event.beta ? event.beta < THRESHOLD : false;
|
||||
flip.value = isUpsideDown;
|
||||
}
|
||||
|
||||
watch(flip, (newState) => {
|
||||
flipEls.forEach(el => {
|
||||
el.classList.toggle('_qrShowFlipFliped', newState);
|
||||
});
|
||||
});
|
||||
|
||||
function requestDeviceMotion() {
|
||||
if (!deviceMotionPermissionNeeded) return;
|
||||
// @ts-expect-error TS(2339)
|
||||
window.DeviceMotionEvent.requestPermission()
|
||||
.then((response: string) => {
|
||||
if (response === 'granted') {
|
||||
window.addEventListener('deviceorientation', handleOrientationChange);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('deviceorientation', handleOrientationChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('deviceorientation', handleOrientationChange);
|
||||
});
|
||||
|
||||
const vFlip = {
|
||||
mounted(el: Element) {
|
||||
flipEls.add(el);
|
||||
el.classList.add('_qrShowFlip');
|
||||
},
|
||||
unmounted(el: Element) {
|
||||
el.classList.remove('_qrShowFlip');
|
||||
flipEls.delete(el);
|
||||
},
|
||||
} as Directive;
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
$s1: 14px;
|
||||
$s2: 21px;
|
||||
$s3: 28px;
|
||||
$avatarSize: 58px;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 230px;
|
||||
border-radius: 12px;
|
||||
overflow: clip;
|
||||
aspect-ratio: 1;
|
||||
|
||||
> svg,
|
||||
> canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: $s3 auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
overflow: visible;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: $avatarSize;
|
||||
height: $avatarSize;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 100px;
|
||||
margin: $s3 auto 0;
|
||||
filter: drop-shadow(0 0 6px #0007);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/*
|
||||
* useCssModuleで$styleを読み込みたかったが、rollupでのunwindが壊れてしまうらしく失敗。
|
||||
* グローバルにクラスを定義することでお茶を濁す。
|
||||
*/
|
||||
._qrShowFlip {
|
||||
transition: rotate .3s linear, scale .3s .15s step-start;
|
||||
}
|
||||
|
||||
._qrShowFlipFliped {
|
||||
scale: -1 1;
|
||||
rotate: x 180deg;
|
||||
}
|
||||
</style>
|
||||
57
packages/frontend/src/pages/qr.vue
Normal file
57
packages/frontend/src/pages/qr.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" class="_pageScrollable">
|
||||
<div class="_spacer" :class="$style.main">
|
||||
<MkButton v-if="read" :class="$style.button" rounded @click="read = false"><i class="ti ti-qrcode"></i> {{ i18n.ts._qr.showTabTitle }}</MkButton>
|
||||
<MkButton v-else :class="$style.button" rounded @click="read = true"><i class="ti ti-scan"></i> {{ i18n.ts._qr.readTabTitle }}</MkButton>
|
||||
|
||||
<MkQrRead v-if="read"/>
|
||||
<MkQrShow v-else/>
|
||||
</div>
|
||||
<MkPolkadots v-if="!read" accented revered :height="200" style="position: sticky; bottom: 0; margin-top: -200px;"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref, shallowRef } from 'vue';
|
||||
import MkQrShow from './qr.show.vue';
|
||||
import { definePage } from '@/page.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ensureSignin } from '@/i';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkPolkadots from '@/components/MkPolkadots.vue';
|
||||
|
||||
// router definitionでloginRequiredが設定されているためエラーハンドリングしない
|
||||
const $i = ensureSignin();
|
||||
|
||||
const read = ref(false);
|
||||
|
||||
const MkQrRead = defineAsyncComponent(() => import('./qr.read.vue'));
|
||||
|
||||
definePage(() => ({
|
||||
title: i18n.ts.qr,
|
||||
icon: 'ti ti-qrcode',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0 auto 16px auto;
|
||||
}
|
||||
</style>
|
||||
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<FormLink @click="chooseUploadFolder()">
|
||||
<SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel>
|
||||
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
|
||||
<template #suffixIcon><i class="ti ti-folder"></i></template>
|
||||
<template #icon><i class="ti ti-folder"></i></template>
|
||||
</FormLink>
|
||||
</SearchMarker>
|
||||
|
||||
|
||||
@@ -151,6 +151,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<hr>
|
||||
|
||||
<SearchMarker :keywords="['qrcode']">
|
||||
<FormLink to="/qr">
|
||||
<template #icon><i class="ti ti-qrcode"></i></template>
|
||||
<SearchLabel>{{ i18n.ts.qr }}</SearchLabel>
|
||||
</FormLink>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
</SearchMarker>
|
||||
</template>
|
||||
@@ -164,6 +173,7 @@ import MkSelect from '@/components/MkSelect.vue';
|
||||
import FormSplit from '@/components/form/split.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import { chooseDriveFile } from '@/utility/drive.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
Reference in New Issue
Block a user