Simplify connection flow, mobile transfer UI, and header actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
30
apps/web/src/viewport.js
Normal 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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user