wip (qr.show.vue)

This commit is contained in:
tamaina 2025-08-25 07:23:43 +09:00
parent 47d83e8930
commit 32b21b1186
10 changed files with 330 additions and 0 deletions

14
locales/index.d.ts vendored
View File

@ -12548,6 +12548,20 @@ export interface Locale extends ILocale {
*/
"listDrafts": string;
};
/**
*
*/
"qr": string;
"_qr": {
/**
*
*/
"showTabTitle": string;
/**
*
*/
"readTabTitle": string;
};
}
declare const locales: {
[lang: string]: Locale;

View File

@ -3359,3 +3359,8 @@ _drafts:
restoreFromDraft: "下書きから復元"
restore: "復元"
listDrafts: "下書き一覧"
qr: 二次元コード
_qr:
showTabTitle: "表示"
readTabTitle: "読み取る"

View File

@ -63,6 +63,7 @@
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"rollup": "4.48.0",
"sanitize-html": "2.17.0",
"sass": "1.90.0",

View File

@ -0,0 +1,44 @@
import type { Directive } from 'vue';
let initialized = false;
let styleEl: HTMLStyleElement | null = null;
const elements = new Set<HTMLElement>();
const className = '_flipOnDeviceOrientation';
const variableName = `--flip_on_device_orientation_transform`;
function handleOrientationChange() {
const isUpsideDown = window.screen.orientation.type === 'landscape-secondary';
const transform = isUpsideDown ? 'scale(-1, -1)' : '';
window.document.body.style.setProperty(variableName, transform);
}
function registerListener() {
if (!initialized) {
screen.orientation.addEventListener('change', handleOrientationChange);
if (!styleEl) {
styleEl = window.document.createElement('style');
styleEl.textContent = `.${className} { transform: var(${variableName}); }`;
window.document.head.appendChild(styleEl);
}
initialized = true;
} else if (window.document.getElementsByClassName(className).length === 0) {
screen.orientation.removeEventListener('change', handleOrientationChange);
if (styleEl) {
window.document.head.removeChild(styleEl);
styleEl = null;
}
initialized = false;
}
}
export default {
mounted(el) {
registerListener();
el.classList.add(className);
handleOrientationChange();
},
unmounted(el) {
el.classList.remove(className);
registerListener();
},
} as Directive;

View File

@ -16,6 +16,7 @@ import clickAnime from './click-anime.js';
import panel from './panel.js';
import adaptiveBorder from './adaptive-border.js';
import adaptiveBg from './adaptive-bg.js';
import flipOnDeviceOrientation from './flip-on-device-orientation.js';
export default function(app: App) {
for (const [key, value] of Object.entries(directives)) {
@ -36,4 +37,5 @@ export const directives = {
'panel': panel,
'adaptive-border': adaptiveBorder,
'adaptive-bg': adaptiveBg,
'flip-on-device-orientation': flipOnDeviceOrientation,
};

View File

@ -0,0 +1,21 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
TODO
</div>
</template>
<script lang="ts" setup>
import { ensureSignin } from '@/i';
const $i = ensureSignin();
</script>
<style lang="scss" module>
.root {
}
</style>

View File

@ -0,0 +1,179 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" :style="{ backgroundColor: bgColor }">
<div :class="$style.content" class="_spacer" :style="{ '--MI_SPACER-w': '512px' }">
<div :class="$style.qrOuter">
<div v-flip-on-device-orientation ref="qrCodeEl" :class="$style.qrInner"></div>
</div>
<div :class="$style.user">
<MkAvatar v-flip-on-device-orientation :class="$style.avatar" :user="$i" :indicator="false"/>
<div :class="$style.names">
<div v-flip-on-device-orientation :class="$style.username"><MkCondensedLine :minScale="2 / 3">@{{ $i.username }}@{{ host }}</MkCondensedLine></div>
<div v-flip-on-device-orientation :class="$style.name"><MkCondensedLine :minScale="2 / 3">{{ userName($i) }}</MkCondensedLine></div>
</div>
</div>
<img v-flip-on-device-orientation :class="$style.logo" :src="misskeysvg" alt="Misskey Logo"/>
</div>
</div>
</template>
<script lang="ts" setup>
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash';
import tinycolor from 'tinycolor2';
import QRCodeStyling from 'qr-code-styling';
import { computed, ref, watch } from 'vue';
import { host } from '@@/js/config.js';
import { instance } from '@/instance';
import { ensureSignin } from '@/i';
import { userPage, userName } from '@/filters/user';
import misskeysvg from '/client-assets/misskey.svg';
const $i = ensureSignin();
const qrCodeEl = ref<HTMLDivElement | null>(null);
const avatarColor = computed(() => tinycolor(
$i.avatarBlurhash ?
extractAvgColorFromBlurhash($i.avatarBlurhash)
: instance.themeColor
?? '#86b300',
));
const avatarHsl = computed(() => avatarColor.value.toHsl());
const bgColor = tinycolor(`hsl(${avatarHsl.value.h}, 60, 46)`).toRgbString();
watch([qrCodeEl, avatarHsl], () => {
const qrCodeInstance = new QRCodeStyling({
width: 512,
height: 512,
margin: 40,
type: 'canvas',
data: userPage($i, undefined, true),
image: instance.iconUrl ?? '/favicon.ico',
qrOptions: {
typeNumber: 0,
mode: 'Byte',
errorCorrectionLevel: 'H',
},
imageOptions: {
hideBackgroundDots: true,
imageSize: 0.3,
margin: 8,
crossOrigin: 'anonymous',
},
dotsOptions: {
color: tinycolor(`hsl(${avatarHsl.value.h}, 100, 18)`).toRgbString(),
gradient: {
type: 'linear',
rotation: 1, // radian
colorStops: [
{ offset: 0, color: tinycolor(`hsl(${avatarHsl.value.h}, 100, 6)`).toRgbString() },
{ offset: 0.5, color: tinycolor(`hsl(${avatarHsl.value.h}, 100, 18)`).toRgbString() },
{ offset: 1, color: tinycolor(`hsl(${avatarHsl.value.h}, 100, 25)`).toRgbString() },
],
},
type: 'classy-rounded',
},
backgroundOptions: {
color: tinycolor(`hsl(${avatarHsl.value.h}, 100, 97)`).toRgbString(),
},
});
if (qrCodeEl.value != null) {
qrCodeInstance.append(qrCodeEl.value);
}
}, { immediate: true });
</script>
<style lang="scss" module>
.root {
margin-top: calc( -1 * var(--MI-stickyTop) );
padding-top: var(--MI-stickyTop);
height: calc( 100dvh - var(--MI-stickyTop) - var(--MI-stickyBottom) );
}
.content {
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto;
}
.qrOuter {
display: flex;
}
.qrInner {
width: 100%;
max-height: 40dvh;
margin: 5dvh auto 2dvh;
> svg,
> canvas {
max-width: 100%;
max-height: 100%;
}
}
$avatarSize: 58px;
.user {
display: flex;
margin: 2dvh auto;
justify-content: center;
align-items: center;
overflow: clip;
width: fit-content;
max-width: 100%;
}
.avatar {
width: $avatarSize;
height: $avatarSize;
margin-right: 8px;
}
.names {
display: flex;
font-weight: bold;
flex-direction: column;
justify-content: center;
align-items: start;
margin-top: -4px;
max-width: 100%;
overflow: hidden;
}
.name {
display: inline-block;
color: #fff;
font-size: 20px;
line-height: 24px;
width: fit-content;
max-width: calc(100% - 16px);
overflow: hidden;
padding-right: 16px;
mask-image: linear-gradient(90deg,#000,#000 calc(100% - 16px),#0000);
}
.username {
display: inline-block;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
line-height: 16px;
width: fit-content;
max-width: calc(100% - 16px);
overflow: hidden;
padding-right: 16px;
mask-image: linear-gradient(90deg,#000,#000 calc(100% - 16px),#0000);
}
.logo {
width: 30%;
margin: 2dvh auto 0;
}
</style>

View File

@ -0,0 +1,44 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<PageWithHeader v-model:tab="tab" :tabs="headerTabs" :swipable="true" :style="{ overflow: tab === 'show' ? 'clip' : undefined }">
<MkQrShow v-if="tab === 'show'"/>
<MkQrRead v-else-if="tab === 'read'"/>
<MkError v-else-if="error" :error="error"/>
<MkLoading v-else/>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, ref, shallowRef } from 'vue';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i';
// router definitionloginRequired
const $i = ensureSignin();
const tab = ref<'read' | 'show'>('show');
const error = ref<any>(null);
const MkQrShow = defineAsyncComponent(() => import('./qr.show.vue'));
const MkQrRead = defineAsyncComponent(() => import('./qr.read.vue'));
const headerTabs = [{
key: 'show',
title: i18n.ts._qr.showTabTitle,
icon: 'ti ti-qrcode',
}, {
key: 'read',
title: i18n.ts._qr.readTabTitle,
icon: 'ti ti-scan',
}];
definePage(() => ({
title: i18n.ts.qr,
icon: 'ti ti-qrcode',
}));
</script>

View File

@ -590,6 +590,10 @@ export const ROUTE_DEF = [{
path: '/reversi/g/:gameId',
component: page(() => import('@/pages/reversi/game.vue')),
loginRequired: false,
}, {
path: '/qr',
component: page(() => import('@/pages/qr.vue')),
loginRequired: true,
}, {
path: '/debug',
component: page(() => import('@/pages/debug.vue')),

View File

@ -844,6 +844,9 @@ importers:
punycode.js:
specifier: 2.3.1
version: 2.3.1
qr-code-styling:
specifier: 1.9.2
version: 1.9.2
rollup:
specifier: 4.48.0
version: 4.48.0
@ -9508,6 +9511,13 @@ packages:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
qr-code-styling@1.9.2:
resolution: {integrity: sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==}
engines: {node: '>=18.18.0'}
qrcode-generator@1.5.2:
resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
@ -21080,6 +21090,12 @@ snapshots:
pvutils@1.1.3: {}
qr-code-styling@1.9.2:
dependencies:
qrcode-generator: 1.5.2
qrcode-generator@1.5.2: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.2