Simplify connection flow, mobile transfer UI, and header actions

This commit is contained in:
2026-10-12 18:14:54 +09:00
parent 5f431ee153
commit 4f0231da3d
22 changed files with 820 additions and 131 deletions

View File

@@ -3,6 +3,8 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import {
ArrowUpRight,
ArrowRight,
ArrowLeft,
LogIn,
Plus,
Monitor,
Smartphone,
@@ -29,19 +31,23 @@ import {
Download,
RotateCcw,
EllipsisVertical,
Ellipsis,
Info,
} from '@lucide/vue';
import QRCode from 'qrcode';
import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
import { createNearbySearch } from './nearbySearch';
import { useVisibleViewport } from './viewport';
useVisibleViewport();
import { createDeliveryReceiver } from './deliveryReceiver';
import { initialTheme, applyTheme as setTheme } from './theme';
import {
device,
account,
connected,
discoveryEnabled,
spaceInviteCodeLength,
locationSettings,
publishLocation,
clearLocation,
@@ -74,12 +80,34 @@ const tab = ref('devices'),
nameDraft = ref(''),
notice = ref(''),
modal = ref(null),
headerMore = ref(false),
connectView = ref('choose'),
joinCode = ref(''),
approvalRequired = ref(false),
invite = ref(null),
qr = ref(''),
now = ref(Date.now());
const localOnlyQr = /^(localhost|127(?:\.\d+){3}|\[::1\])$/.test(location.hostname);
const normalizedJoinCode = computed(() => joinCode.value.replace(/[\s-]/g, '').toUpperCase());
const validJoinCode = computed(() =>
/^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/.test(normalizedJoinCode.value),
);
const connectTitle = computed(() =>
connectView.value === 'join'
? '연결 공간 참여'
: connectView.value === 'choose'
? '다른 기기 연결'
: '연결 공간 만들기',
);
watch(modal, (value) => {
if (value === 'connect') {
connectView.value =
invite.value && invite.value.space.expiresAt > now.value ? 'invite' : 'choose';
joinCode.value = '';
}
});
function formatInviteCode(code) {
return code.match(new RegExp(`.{1,${code.length === 6 ? 3 : 4}}`, 'g')).join(' ');
}
const theme = ref(initialTheme());
const media = matchMedia('(prefers-color-scheme: dark)');
function applyTheme() {
@@ -133,6 +161,49 @@ async function receiveAction(r, action) {
});
}
const deviceMenu = ref(null);
async function toggleHeaderMore() {
if (headerMore.value) {
closeHeaderMore(true);
return;
}
closeDeviceMenu();
headerMore.value = true;
await nextTick();
document.querySelector('#header-more-menu [role="menuitem"]')?.focus();
}
function closeHeaderMore(restore = false) {
headerMore.value = false;
if (restore) document.getElementById('header-more-toggle')?.focus();
}
function headerMenuKey(event) {
if (event.key === 'Escape') {
event.preventDefault();
closeHeaderMore(true);
return;
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const options = [...event.currentTarget.querySelectorAll('[role="menuitem"]')].filter(
(el) => el.getClientRects().length,
);
const current = options.indexOf(document.activeElement);
const index =
event.key === 'Home'
? 0
: event.key === 'End'
? options.length - 1
: (current + (event.key === 'ArrowUp' ? -1 : 1) + options.length) % options.length;
options[index]?.focus();
}
function openAbout() {
closeHeaderMore();
modal.value = 'about';
}
function changeTheme() {
toggleTheme();
closeHeaderMore(true);
}
function isHost(id) {
return Boolean(id && spaces.value.some((s) => s.creatorDeviceId === id));
}
@@ -192,8 +263,13 @@ function closeDeviceMenu(restore = false) {
}
function outsideDeviceMenu(event) {
if (!event.target.closest('[data-device-menu]')) closeDeviceMenu();
if (!event.target.closest('[data-header-more]')) closeHeaderMore();
}
function escapeDeviceMenu(event) {
if (event.key === 'Escape' && headerMore.value) {
event.preventDefault();
closeHeaderMore(true);
}
if (event.key === 'Escape' && deviceMenu.value) {
event.preventDefault();
closeDeviceMenu(true);
@@ -436,7 +512,8 @@ async function openDeviceSettings() {
nameDraft.value = device.value?.name ?? '';
modal.value = 'device';
await nextTick();
document.querySelector('.device-settings-form input')?.focus();
if (!matchMedia('(max-width: 639px)').matches)
document.querySelector('.device-settings-form input')?.focus();
}
async function saveName() {
await act(async () => {
@@ -480,12 +557,13 @@ async function createSpace() {
color: { dark: '#111111', light: '#ffffff' },
errorCorrectionLevel: 'M',
});
connectView.value = 'invite';
await refresh();
});
}
async function joinSpace() {
await act(async () => {
await api('/spaces/join', 'POST', { code: joinCode.value.replace(/[\s-]/g, '').toUpperCase() });
await api('/spaces/join', 'POST', { code: normalizedJoinCode.value });
joinCode.value = '';
await refresh();
modal.value = null;
@@ -556,16 +634,50 @@ async function spaceAction(space, action, memberId) {
<span class="header-tagline">기기 사이를 가볍게.</span>
</div>
<div class="header-actions">
<InstallButton />
<button
class="icon-button theme-toggle"
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
@click="toggleTheme"
>
<Moon v-if="theme === 'light'" :size="19" /><Sun v-else :size="19" /></button
><button class="login-button" @click="tab = 'vault'">
<button class="login-button" @click="tab = 'vault'">
{{ account ? '보관함' : '로그인' }} <ArrowUpRight :size="15" />
</button>
<div class="header-more-wrap" data-header-more>
<button
id="header-more-toggle"
class="icon-button header-more-toggle"
aria-label="더보기"
aria-haspopup="menu"
:aria-expanded="headerMore"
aria-controls="header-more-menu"
@click="toggleHeaderMore"
>
<Ellipsis :size="22" />
</button>
<!-- Keep install listeners mounted even when the menu is closed. -->
<div
v-show="headerMore"
id="header-more-menu"
class="header-more-menu"
role="menu"
aria-label="더보기"
@keydown="headerMenuKey"
>
<InstallButton
:menu-item="true"
@open="closeHeaderMore()"
@closed="closeHeaderMore(true)"
/>
<button
class="theme-toggle"
role="menuitem"
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
@click="changeTheme"
>
<Moon v-if="theme === 'light'" :size="18" /><Sun v-else :size="18" /><span>{{
theme === 'light' ? '다크 테마' : '라이트 테마'
}}</span>
</button>
<button role="menuitem" @click="openAbout">
<Info :size="18" /><span>서비스 정보</span>
</button>
</div>
</div>
</div>
</header>
<main>
@@ -807,13 +919,13 @@ async function spaceAction(space, action, memberId) {
v-if="canInvite"
key="qr-connect"
class="qr-connect-card"
:aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'"
:aria-label="spaces.length ? '초대 보기' : '다른 기기 연결'"
@click="modal = 'connect'"
>
<QrCode :size="32" /><strong>{{
spaces.length ? 'QR 초대 보기' : 'QR로 연결'
spaces.length ? '초대 보기' : '다른 기기 연결'
}}</strong
><span>다른 기기 연결하기</span>
><span>QR 또는 코드</span>
</button>
</TransitionGroup>
</section>
@@ -925,58 +1037,108 @@ async function spaceAction(space, action, memberId) {
class="modal"
role="dialog"
aria-modal="true"
:aria-label="modal === 'connect' ? 'QR·코드 연결' : '설정'"
:aria-label="
modal === 'connect' ? connectTitle : modal === 'about' ? '서비스 정보' : '설정'
"
>
<header>
<h2>{{ modal === 'connect' ? 'QR · 코드로 연결' : '내 기기 설정' }}</h2>
<div class="connect-dialog-title">
<button
v-if="modal === 'connect' && ['create', 'join'].includes(connectView)"
class="icon-button"
:disabled="busy"
@click="connectView = 'choose'"
aria-label="연결 방법 선택으로 돌아가기"
>
<ArrowLeft :size="20" />
</button>
<h2>
{{
modal === 'connect'
? connectTitle
: modal === 'about'
? '서비스 정보'
: '내 기기 설정'
}}
</h2>
</div>
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
<X :size="20" />
</button>
</header>
<template v-if="modal === 'connect'"
><p class="modal-subtitle">
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
</p>
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
<p class="discovery-status">기기 참여를 기다리고 있어요.</p>
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
<p v-if="localOnlyQr" class="local-qr-note">
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의
다른 브라우저에서 아래 코드를 입력해 주세요.
</p>
<div class="invite-code">{{ invite.code.slice(0, 4) }} {{ invite.code.slice(4) }}</div>
<button class="text-button" @click="copyValue(invite.code)">
<Copy :size="14" />코드 복사
</button>
<p>
{{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 · 최대
10대
</p>
</div>
<div v-else-if="!spaces.length" class="create-space">
<template v-if="modal === 'connect'">
<template v-if="connectView === 'choose'">
<p class="modal-subtitle">서로 다른 네트워크에서도 기기를 연결할 수 있어요.</p>
<div class="connection-choices">
<button
class="connection-choice"
aria-label="연결 공간 만들기"
@click="connectView = 'create'"
>
<QrCode :size="30" /><strong>연결 공간 만들기</strong
><span>QR이나 코드로 초대</span>
</button>
<button
class="connection-choice"
aria-label="연결 공간 참여"
@click="connectView = 'join'"
>
<LogIn :size="30" /><strong>연결 공간 참여</strong><span>초대 코드로 참여</span>
</button>
</div>
</template>
<form
v-else-if="connectView === 'create'"
class="create-space"
@submit.prevent="createSpace"
>
<p class="modal-subtitle">QR이나 초대 코드로 기기를 초대하세요.</p>
<label class="checkbox-label"
><input type="checkbox" v-model="approvalRequired" />참여할 때 내 승인 받기</label
><button class="button primary" @click="createSpace" :disabled="busy">
<Plus :size="18" />연결 공간 만들기
>
<button class="button primary" type="submit" :disabled="busy">
만들기 <ArrowRight :size="16" />
</button>
</div>
<div v-if="!spaces.length" class="modal-divider"><span>또는 코드로 참여</span></div>
<form v-if="!spaces.length" @submit.prevent="joinSpace" class="join-form">
</form>
<form v-else-if="connectView === 'join'" class="join-form" @submit.prevent="joinSpace">
<p class="modal-subtitle">상대방에게 받은 초대 코드를 입력하세요.</p>
<input
v-model="joinCode"
aria-label="연결 코드"
placeholder="8자리 연결 코드"
:placeholder="`초대 코드 ${spaceInviteCodeLength}자리`"
maxlength="10"
autocomplete="off"
autocapitalize="characters"
/><button
class="button"
type="submit"
:disabled="busy || joinCode.replace(/[\s-]/g, '').length !== 8"
>
참여 <ArrowRight :size="16" />
</button></form
></template>
spellcheck="false"
/>
<button class="button primary" type="submit" :disabled="busy || !validJoinCode">
참여하기 <ArrowRight :size="16" />
</button>
</form>
<div v-else-if="invite && invite.space.expiresAt > now" class="invite-display">
<img :src="qr" alt="연결 공간 초대 QR 코드" width="208" height="208" />
<div class="invite-code">{{ formatInviteCode(invite.code) }}</div>
<p class="invite-validity">
{{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 유효
</p>
<button class="button invite-copy" :disabled="busy" @click="copyValue(invite.code)">
<Copy :size="16" />코드 복사
</button>
<p class="invite-waiting">다른 기기의 참여를 기다리고 있어요.</p>
</div>
<p v-else class="modal-subtitle">초대가 만료됐어요.</p>
</template>
<template v-else-if="modal === 'about'">
<div class="service-info">
<strong>hop.</strong>
<p>기기 사이를 가볍게.</p>
<p>
파일과 링크를 다른 기기로 보낼 수 있어요.<br />보관함에 저장한 자료는 최대 7일 후
삭제됩니다.
</p>
<small>by noname.company</small>
</div>
</template>
<template v-else
><form class="name-form device-settings-form" @submit.prevent="saveName">
<label

View File

@@ -1,6 +1,8 @@
<script setup>
import { ref, shallowRef, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { Download, X } from '@lucide/vue';
const props = defineProps({ menuItem: Boolean });
const emit = defineEmits(['open', 'closed']);
const standalone = window.matchMedia('(display-mode: standalone)');
const installed = ref(standalone.matches || navigator.standalone === true);
const prompt = shallowRef(null),
@@ -11,7 +13,7 @@ const ios =
/iPhone|iPad|iPod/.test(navigator.userAgent) ||
(/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
const mobile = ios || /Android/.test(navigator.userAgent);
const visible = computed(() => !installed.value && (mobile || prompt.value));
const visible = computed(() => !installed.value && (props.menuItem || mobile || prompt.value));
function available(event) {
event.preventDefault();
prompt.value = event;
@@ -25,6 +27,7 @@ function displayChanged() {
installed.value = standalone.matches || navigator.standalone === true;
}
async function install() {
emit('open');
if (prompt.value) {
const event = prompt.value;
prompt.value = null;
@@ -43,6 +46,7 @@ async function install() {
function close() {
help.value = false;
button.value?.focus();
emit('closed');
}
onMounted(() => {
window.addEventListener('beforeinstallprompt', available);
@@ -60,10 +64,11 @@ onUnmounted(() => {
v-if="visible"
ref="button"
class="pwa-install-button"
:role="menuItem ? 'menuitem' : undefined"
aria-label="hop. 앱 설치"
@click="install"
>
<Download :size="16" /><span>설치</span>
<Download :size="16" /><span>{{ menuItem ? '앱 설치' : '설치' }}</span>
</button>
<Teleport to="body">
<div v-if="help" class="modal-backdrop" @click.self="close" @keydown.esc.stop="close">

View File

@@ -267,6 +267,9 @@ function submit() {
else if (source.value === 'file') emit('files', [...files.value]);
else emit('vault', selectedVault.value);
}
function fileProgress(index) {
return props.progress?.find((upload) => upload.index === index) ?? props.progress?.[index];
}
function size(value) {
return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
@@ -335,18 +338,32 @@ function size(value) {
</div>
<ul class="selected-files">
<li v-for="(file, index) in files" :key="index">
<FileIcon :size="15" /><span>{{ file.name }}</span
><small>{{ size(file.size) }}</small
><button
class="icon-button"
:disabled="!mutable"
:aria-label="`${file.name} 선택 해제`"
@click="files.splice(index, 1)"
>
<X :size="15" />
</button>
</li></ul
></template>
<div class="selected-file-row">
<FileIcon :size="15" /><span>{{ file.name }}</span
><small>{{ size(file.size) }}</small
><button
class="icon-button"
:disabled="!mutable"
:aria-label="`${file.name} 선택 해제`"
@click="files.splice(index, 1)"
>
<X :size="15" />
</button>
</div>
<div v-if="fileProgress(index)" class="file-transfer-progress">
<progress
:value="fileProgress(index).progress"
max="100"
:aria-label="`${file.name} 전송 진행률`"
/>
<small>{{ fileProgress(index).progress }}%</small>
<span v-if="fileProgress(index).status === 'failed'" role="status">{{
fileProgress(index).message
}}</span>
</div>
</li>
</ul></template
>
</template>
<template v-else>
<div class="content-heading">
@@ -399,7 +416,11 @@ function size(value) {
</button></template
>
</template>
<p v-if="transfer" class="composer-transfer-state" role="status">
<p
v-if="transfer && (!progress?.length || retryable)"
class="composer-transfer-state"
role="status"
>
{{
retryable
? '같은 파일로 이어 보낼 수 있어요.'
@@ -408,12 +429,13 @@ function size(value) {
: '전송 중입니다.'
}}
</p>
<div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span
>{{ upload.name }} ·
{{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span
><progress :value="upload.progress" max="100" />
</div>
<template v-if="!files.length"
><div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span
>{{ upload.name }} ·
{{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span
><progress :value="upload.progress" max="100" /></div
></template>
</div>
<button
class="button primary content-submit"

View File

@@ -28,6 +28,7 @@ export const pendingPeers = ref([]);
let pendingTimer;
let deviceInfo = {};
export const nextCursor = ref(null);
export const spaceInviteCodeLength = ref(4);
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
export async function publishLocation(position) {
if (!socket?.connected) throw Error('서버 연결을 확인해 주세요.');
@@ -150,6 +151,7 @@ export const refresh = createRefreshQueue(async () => {
(id) => !snapshot.devices.some((peer) => peer.id === id),
);
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
spaceInviteCodeLength.value = snapshot.spaceInviteCodeLength ?? 4;
locationSettings.value = snapshot.locationDiscovery ?? {
enabled: false,
radiusMeters: 100,
@@ -296,6 +298,7 @@ export async function uploadFile(id, index, file, accountId) {
controller = new AbortController();
uploadProgress.value[progressKey] = {
name: file.name,
index,
progress: 0,
status: 'uploading',
accountId,
@@ -407,6 +410,7 @@ export async function uploadFile(id, index, file, accountId) {
check();
uploadProgress.value[progressKey] = {
name: file.name,
index,
progress: 100,
status: 'stored',
accountId,
@@ -417,6 +421,7 @@ export async function uploadFile(id, index, file, accountId) {
else
uploadProgress.value[progressKey] = {
name: file.name,
index,
progress: 0,
status: 'failed',
accountId,

View File

@@ -1031,7 +1031,7 @@ h1 span {
.modal-backdrop {
position: fixed;
inset: 0;
background: rgb(16 25 18 / 0.4);
background: rgb(0 0 0 / 0.4);
display: flex;
align-items: center;
justify-content: center;
@@ -1612,15 +1612,10 @@ h1 span {
list-style: none;
}
.selected-files li {
display: flex;
gap: 8px;
align-items: center;
display: block;
margin-bottom: 14px;
font-size: 11px;
}
.selected-files li > span {
flex: 1;
overflow-wrap: anywhere;
}
.selected-files small {
color: var(--muted);
white-space: nowrap;
@@ -2024,17 +2019,6 @@ h1 span {
}
}
.local-qr-note {
max-width: 280px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
font-size: 10px !important;
line-height: 1.8;
text-align: center;
}
.account-security-actions {
display: flex;
gap: 10px;
@@ -3230,3 +3214,242 @@ a.button {
opacity: 0;
}
}
.connect-dialog-title {
display: flex;
align-items: center;
gap: 10px;
}
.connection-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.connection-choice {
min-height: 175px;
border: 1px solid var(--line);
background: var(--surface);
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 18px 12px;
}
.connection-choice:hover {
border-color: var(--muted);
}
.connection-choice strong {
font-size: 15px;
font-weight: 500;
}
.connection-choice span {
font-size: 12px;
color: var(--muted);
}
.modal .join-form,
.modal .create-space {
display: flex;
flex-direction: column;
gap: 18px;
}
.modal .join-form input {
width: 100%;
padding: 14px;
border-radius: 12px;
}
.modal .join-form .button,
.modal .create-space .button {
width: 100%;
border-radius: 24px;
}
.modal .invite-display {
gap: 12px;
}
.modal .invite-code {
font-size: 32px;
letter-spacing: 0.04em;
}
.modal .invite-validity,
.modal .invite-waiting {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.modal .invite-copy {
width: 100%;
border-radius: 24px;
background: var(--line);
margin: 8px 0;
}
@media (max-width: 420px) {
.connection-choice {
min-height: 155px;
padding: 16px 8px;
}
.connection-choice strong {
font-size: 13px;
}
.connection-choice span {
font-size: 11px;
}
}
.selected-file-row {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.selected-file-row > span {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.file-transfer-progress {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin: 8px 0 0 23px;
}
.file-transfer-progress > span {
grid-column: 1 / -1;
font-size: 11px;
color: var(--muted);
overflow-wrap: anywhere;
}
.file-transfer-progress progress {
width: 100%;
height: 5px;
border: 0;
appearance: none;
border-radius: 4px;
overflow: hidden;
background: var(--line);
}
.file-transfer-progress progress::-webkit-progress-bar {
background: var(--line);
border-radius: 4px;
}
.file-transfer-progress progress::-webkit-progress-value {
background: var(--ink);
border-radius: 4px;
}
.file-transfer-progress progress::-moz-progress-bar {
background: var(--ink);
border-radius: 4px;
}
@media (max-width: 760px), (hover: none) and (pointer: coarse) {
input:not([type='checkbox']):not([type='radio']):not([type='file']),
textarea,
select {
font-size: 16px !important;
}
}
@media (max-width: 639px) {
.modal-backdrop {
align-items: flex-end;
padding: 0;
}
.modal-backdrop > .modal {
width: 100%;
max-width: none;
max-height: calc(100dvh - 24px);
border-radius: 22px 22px 0 0;
padding: 22px 20px calc(22px + env(safe-area-inset-bottom));
}
.modal .device-settings-form {
flex-direction: column;
align-items: stretch;
height: auto;
gap: 16px;
}
.modal .device-settings-form label,
.modal .device-settings-form input {
width: 100%;
min-width: 0;
}
.modal .device-settings-form input {
min-height: 44px;
}
.modal .device-settings-form .button {
width: 100%;
min-height: 44px;
}
.modal .discovery-setting {
margin-top: 20px;
align-items: center;
flex-direction: row;
gap: 12px;
}
}
@media (max-width: 639px) {
.modal-backdrop,
.send-backdrop {
top: var(--hop-visible-top, 0px);
bottom: auto;
height: var(--hop-visible-height, 100dvh);
}
.modal-backdrop > .modal,
.send-backdrop .send-dialog {
max-height: calc(var(--hop-visible-height, 100dvh) - 24px);
}
}
.header-more-wrap {
position: relative;
}
.header-more-menu {
position: absolute;
top: calc(100% + 12px);
right: 0;
width: 220px;
max-width: calc(100vw - 40px);
padding: 8px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--soft);
box-shadow: 0 12px 32px #0003;
z-index: 50;
}
.header-more-menu > button {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px;
min-height: 44px;
text-align: left;
border-radius: 8px;
font-size: 13px;
color: var(--ink);
}
.header-more-menu > button:hover {
background: var(--hover);
}
.header-more-menu > button svg {
flex-shrink: 0;
color: var(--muted);
}
.header-more-menu .pwa-install-button span {
display: inline;
}
.service-info {
padding: 24px 0 4px;
font-size: 13px;
line-height: 1.8;
}
.service-info strong {
font-size: 30px;
letter-spacing: -0.07em;
}
.service-info p {
margin: 12px 0;
color: var(--muted);
}
.service-info small {
color: var(--muted);
}

30
apps/web/src/viewport.js Normal file
View File

@@ -0,0 +1,30 @@
import { onMounted, onUnmounted } from 'vue';
// Keep mobile sheets inside the visible area when the software keyboard opens.
export function useVisibleViewport() {
const viewport = window.visualViewport;
let frame;
const update = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const height = viewport?.height ?? innerHeight;
const top = viewport?.offsetTop ?? 0;
document.documentElement.style.setProperty('--hop-visible-height', `${height}px`);
document.documentElement.style.setProperty('--hop-visible-top', `${top}px`);
});
};
onMounted(() => {
viewport?.addEventListener('resize', update);
viewport?.addEventListener('scroll', update);
window.addEventListener('resize', update);
update();
});
onUnmounted(() => {
cancelAnimationFrame(frame);
viewport?.removeEventListener('resize', update);
viewport?.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
document.documentElement.style.removeProperty('--hop-visible-height');
document.documentElement.style.removeProperty('--hop-visible-top');
});
}