Add Korean Japanese and English user interfaces
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { formatAdminDate as expiryDate } from './displayFormat';
|
||||
import { t, intlLanguage, userExpiry as expiryDate, systemMessage } from './i18n';
|
||||
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted, inject } from 'vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowRight,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
clipboardTextLimitBytes,
|
||||
fileTransfersAllowed,
|
||||
} from './client';
|
||||
const sharedNotice = inject('hop-notify', null);
|
||||
const props = defineProps({ initialView: { type: String, default: 'inbox' } });
|
||||
const emit = defineEmits(['devices', 'send-item']);
|
||||
const view = ref(props.initialView === 'vault' ? 'vault' : 'inbox');
|
||||
@@ -79,7 +81,7 @@ watch(preferencesOpen, async (open) => {
|
||||
preferencesTrigger = document.activeElement;
|
||||
await nextTick();
|
||||
preferencesDialog.value
|
||||
?.querySelector('[aria-label="계정 설정 닫기"]')
|
||||
?.querySelector('[data-close-preferences]')
|
||||
?.focus({ preventScroll: true });
|
||||
} else if (preferencesTrigger?.isConnected) {
|
||||
preferencesTrigger.focus({ preventScroll: true });
|
||||
@@ -89,7 +91,7 @@ watch(securityAction, async () => {
|
||||
if (!preferencesOpen.value) return;
|
||||
await nextTick();
|
||||
preferencesDialog.value
|
||||
?.querySelector('[aria-label="계정 설정 닫기"]')
|
||||
?.querySelector('[data-close-preferences]')
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
function preferencesKeydown(event) {
|
||||
@@ -123,6 +125,10 @@ function closePreferences() {
|
||||
}
|
||||
let timer;
|
||||
function message(text) {
|
||||
if (sharedNotice) {
|
||||
sharedNotice(text);
|
||||
return;
|
||||
}
|
||||
notice.value = text;
|
||||
setTimeout(() => {
|
||||
if (notice.value === text) notice.value = '';
|
||||
@@ -141,7 +147,7 @@ async function act(fn) {
|
||||
}
|
||||
|
||||
function date(value) {
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
return new Intl.DateTimeFormat(intlLanguage(), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
@@ -151,7 +157,7 @@ function date(value) {
|
||||
const groups = computed(() => {
|
||||
const grouped = new Map();
|
||||
for (const item of items.value) {
|
||||
const day = new Intl.DateTimeFormat('ko-KR', {
|
||||
const day = new Intl.DateTimeFormat(intlLanguage(), {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
@@ -269,7 +275,9 @@ async function authSubmit() {
|
||||
authMode.value = 'login';
|
||||
reconnect();
|
||||
await refreshLists();
|
||||
message(signupVerification ? '이메일 인증을 완료했어요.' : '이메일 주소를 변경했습니다.');
|
||||
message(
|
||||
signupVerification ? t('이메일 인증을 완료했어요.') : t('이메일 주소를 변경했습니다.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await api('/auth/reset-password', 'POST', {
|
||||
@@ -279,7 +287,7 @@ async function authSubmit() {
|
||||
});
|
||||
token.value = '';
|
||||
authMode.value = 'login';
|
||||
authMessage.value = '비밀번호를 변경했습니다. 다시 로그인해 주세요.';
|
||||
authMessage.value = t('비밀번호를 변경했습니다. 다시 로그인해 주세요.');
|
||||
password.value = '';
|
||||
confirmation.value = '';
|
||||
});
|
||||
@@ -298,7 +306,7 @@ async function save(values) {
|
||||
let id = saveTransfer.value?.id;
|
||||
try {
|
||||
if (values.files && !fileTransfersAllowed.value && !id)
|
||||
throw Error('지금은 링크와 텍스트만 저장할 수 있어요.');
|
||||
throw Error(t('지금은 링크와 텍스트만 저장할 수 있어요.'));
|
||||
const result = id
|
||||
? { requestId: id }
|
||||
: await api(
|
||||
@@ -332,7 +340,7 @@ async function save(values) {
|
||||
file.size !== saveTransfer.value.files[index].size,
|
||||
))
|
||||
)
|
||||
throw Error('처음 선택한 파일과 이름·크기가 같은 파일을 선택해 주세요.');
|
||||
throw Error(t('처음 선택한 파일과 이름·크기가 같은 파일을 선택해 주세요.'));
|
||||
saveTransfer.value = {
|
||||
id,
|
||||
kind: 'file',
|
||||
@@ -351,7 +359,7 @@ async function save(values) {
|
||||
saveTransfer.value = null;
|
||||
compose.value = false;
|
||||
note.value = '';
|
||||
message('임시 보관함에 저장했습니다.');
|
||||
message(t('임시 보관함에 저장했습니다.'));
|
||||
} catch (error) {
|
||||
if (!id || saveTransfer.value?.id === id || !values.files) showError(error);
|
||||
} finally {
|
||||
@@ -378,7 +386,7 @@ async function copy(item) {
|
||||
await act(async () => {
|
||||
await navigator.clipboard.writeText(item.kind === 'text' ? item.text : item.url);
|
||||
await read(item);
|
||||
message('복사했습니다.');
|
||||
message(t('복사했습니다.'));
|
||||
});
|
||||
}
|
||||
async function download(item) {
|
||||
@@ -439,7 +447,7 @@ async function securitySubmit() {
|
||||
clearPrivate();
|
||||
await refreshAccount();
|
||||
reconnect();
|
||||
message('비밀번호를 변경했습니다. 다시 로그인해 주세요.');
|
||||
message(t('비밀번호를 변경했습니다. 다시 로그인해 주세요.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -493,41 +501,43 @@ async function resendVerification() {
|
||||
<h1>
|
||||
{{
|
||||
{
|
||||
login: '다시 가볍게.',
|
||||
register: '이메일로 시작하기.',
|
||||
forgot: '비밀번호 찾기.',
|
||||
verify: '이메일 인증.',
|
||||
reset: '새 비밀번호.',
|
||||
'email-change': '이메일 변경 인증.',
|
||||
login: t('다시 가볍게.'),
|
||||
register: t('이메일로 시작하기.'),
|
||||
forgot: t('비밀번호 찾기.'),
|
||||
verify: t('이메일 인증.'),
|
||||
reset: t('새 비밀번호.'),
|
||||
'email-change': t('이메일 변경 인증.'),
|
||||
}[authMode]
|
||||
}}
|
||||
</h1>
|
||||
<p>계정으로 저장하고, 다른 기기에서도 꺼내 쓰세요.</p>
|
||||
<p>{{ t('계정으로 저장하고, 다른 기기에서도 꺼내 쓰세요.') }}</p>
|
||||
<form @submit.prevent="authSubmit">
|
||||
<label v-if="['login', 'register', 'forgot'].includes(authMode)"
|
||||
>이메일<input
|
||||
>{{ t('이메일')
|
||||
}}<input
|
||||
type="email"
|
||||
v-model="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com" /></label
|
||||
><label v-if="authMode === 'register'"
|
||||
>표시 이름<input
|
||||
v-model="displayName"
|
||||
required
|
||||
maxlength="40"
|
||||
autocomplete="nickname" /></label
|
||||
>{{ t('표시 이름')
|
||||
}}<input v-model="displayName" required maxlength="40" autocomplete="nickname" /></label
|
||||
><label v-if="['login', 'register', 'reset'].includes(authMode)"
|
||||
>비밀번호<input
|
||||
>{{ t('비밀번호')
|
||||
}}<input
|
||||
type="password"
|
||||
v-model="password"
|
||||
required
|
||||
:minlength="authMode === 'login' ? 1 : passwordMinLength"
|
||||
maxlength="1024"
|
||||
:autocomplete="authMode === 'login' ? 'current-password' : 'new-password'"
|
||||
:placeholder="authMode === 'login' ? '비밀번호' : `${passwordMinLength}자 이상`" /></label
|
||||
:placeholder="
|
||||
authMode === 'login' ? t('비밀번호') : t('{p0}자 이상', { p0: passwordMinLength })
|
||||
" /></label
|
||||
><label v-if="['register', 'reset'].includes(authMode)"
|
||||
>비밀번호 확인<input
|
||||
>{{ t('비밀번호 확인')
|
||||
}}<input
|
||||
type="password"
|
||||
v-model="confirmation"
|
||||
required
|
||||
@@ -535,29 +545,29 @@ async function resendVerification() {
|
||||
maxlength="1024"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<p v-if="authMessage" class="auth-message" role="status">{{ authMessage }}</p>
|
||||
<p v-if="authMessage" class="auth-message" role="status">{{ systemMessage(authMessage) }}</p>
|
||||
<button class="button primary" type="submit" :disabled="busy">
|
||||
{{
|
||||
{
|
||||
login: '로그인',
|
||||
register: '가입하고 인증 메일 받기',
|
||||
forgot: '안내 메일 받기',
|
||||
verify: '이메일 인증 완료하기',
|
||||
reset: '비밀번호 변경하기',
|
||||
'email-change': '이메일 변경 확정하기',
|
||||
login: t('로그인'),
|
||||
register: t('가입하고 인증 메일 받기'),
|
||||
forgot: t('안내 메일 받기'),
|
||||
verify: t('이메일 인증 완료하기'),
|
||||
reset: t('비밀번호 변경하기'),
|
||||
'email-change': t('이메일 변경 확정하기'),
|
||||
}[authMode]
|
||||
}}<ArrowRight :size="17" />
|
||||
</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<button v-if="authMode === 'login'" class="text-button" @click="authMode = 'forgot'">
|
||||
비밀번호 찾기</button
|
||||
{{ t('비밀번호 찾기') }}</button
|
||||
><button
|
||||
v-if="authMode === 'login' && registrationOpen"
|
||||
class="text-button"
|
||||
@click="authMode = 'register'"
|
||||
>
|
||||
이메일로 가입</button
|
||||
{{ t('이메일로 가입') }}</button
|
||||
><button
|
||||
v-if="authMode !== 'login'"
|
||||
class="text-button"
|
||||
@@ -566,31 +576,33 @@ async function resendVerification() {
|
||||
token = '';
|
||||
"
|
||||
>
|
||||
로그인으로 돌아가기
|
||||
{{ t('로그인으로 돌아가기') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!registrationOpen && authMode === 'login'" class="muted">
|
||||
현재 공개 가입은 닫혀 있습니다.
|
||||
{{ t('현재 공개 가입은 닫혀 있습니다.') }}
|
||||
</p>
|
||||
<button class="text-button" @click="emit('devices')">
|
||||
가입 없이 기기로 보내기 <ArrowUpRight :size="15" />
|
||||
{{ t('가입 없이 기기로 보내기') }}<ArrowUpRight :size="15" />
|
||||
</button>
|
||||
</section>
|
||||
<section v-else-if="!account.verified" class="account-auth">
|
||||
<h1>이메일을 확인해 주세요<span>.</span></h1>
|
||||
<p>{{ account.email }} 인증 후 이메일 전송과 계정 보관함을 사용할 수 있어요.</p>
|
||||
<button class="button" @click="resendVerification">인증 메일 다시 받기</button
|
||||
><button class="text-button" @click="logout">로그아웃</button>
|
||||
<h1>{{ t('이메일을 확인해 주세요') }}<span>.</span></h1>
|
||||
<p>
|
||||
{{ t('{p0} 인증 후 이메일 전송과 계정 보관함을 사용할 수 있어요.', { p0: account.email }) }}
|
||||
</p>
|
||||
<button class="button" @click="resendVerification">{{ t('인증 메일 다시 받기') }}</button
|
||||
><button class="text-button" @click="logout">{{ t('로그아웃') }}</button>
|
||||
</section>
|
||||
<section v-else class="account-workspace compact-workspace">
|
||||
<div class="account-heading compact-heading">
|
||||
<div>
|
||||
<h1>보관함</h1>
|
||||
<h1>{{ t('보관함') }}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<button class="icon-button" @click="openPreferences" aria-label="계정 설정">
|
||||
<button class="icon-button" @click="openPreferences" :aria-label="t('계정 설정')">
|
||||
<Settings :size="18" /></button
|
||||
><button class="icon-button" @click="logout" aria-label="로그아웃">
|
||||
><button class="icon-button" @click="logout" :aria-label="t('로그아웃')">
|
||||
<LogOut :size="18" /></button
|
||||
><button
|
||||
v-if="view === 'vault'"
|
||||
@@ -601,51 +613,51 @@ async function resendVerification() {
|
||||
note = '';
|
||||
"
|
||||
>
|
||||
<Plus :size="17" />보관함에 담기
|
||||
<Plus :size="17" />{{ t('보관함에 담기') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!fileTransfersAllowed" class="file-policy-note" role="status">
|
||||
새 파일 전송·저장은 제한되어 있어요. 링크와 텍스트는 계속 사용할 수 있어요.
|
||||
{{ t('새 파일 전송·저장은 제한되어 있어요. 링크와 텍스트는 계속 사용할 수 있어요.') }}
|
||||
</p>
|
||||
<div class="account-tabs" role="tablist" aria-label="보관함 목록">
|
||||
<div class="account-tabs" role="tablist" :aria-label="t('보관함 목록')">
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="view === 'inbox'"
|
||||
:class="{ active: view === 'inbox' }"
|
||||
@click="view = 'inbox'"
|
||||
>
|
||||
받은 항목</button
|
||||
{{ t('받은 항목') }}</button
|
||||
><button
|
||||
role="tab"
|
||||
:aria-selected="view === 'vault'"
|
||||
:class="{ active: view === 'vault' }"
|
||||
@click="view = 'vault'"
|
||||
>
|
||||
임시 저장
|
||||
{{ t('임시 저장') }}
|
||||
</button>
|
||||
</div>
|
||||
<form class="compact-search" @submit.prevent="act(refreshLists)">
|
||||
<input
|
||||
type="search"
|
||||
v-model="search"
|
||||
placeholder="링크, 텍스트, 파일, 메모 검색"
|
||||
aria-label="계정 자료 검색"
|
||||
/><button class="button small">검색</button>
|
||||
:placeholder="t('링크, 텍스트, 파일, 메모 검색')"
|
||||
:aria-label="t('계정 자료 검색')"
|
||||
/><button class="button small">{{ t('검색') }}</button>
|
||||
</form>
|
||||
<button v-if="selected.length" class="button bulk-delete" @click="remove(selected)">
|
||||
<Trash2 :size="16" />선택 삭제
|
||||
<Trash2 :size="16" />{{ t('선택 삭제') }}
|
||||
</button>
|
||||
<section v-for="group in groups" :key="group.date" class="dated-items">
|
||||
<h2>{{ group.date }} {{ view === 'inbox' ? '받은 항목' : '저장한 항목' }}</h2>
|
||||
<h2>{{ t(view === 'inbox' ? '{p0} 받은 항목' : '{p0} 저장한 항목', { p0: group.date }) }}</h2>
|
||||
<article v-for="item in group.items" :key="item.accessId" class="item-card compact-item">
|
||||
<div class="item-heading">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="selected"
|
||||
:value="item.accessId"
|
||||
:aria-label="`${item.file?.name ?? item.url ?? item.text} 선택`"
|
||||
/><span>{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}</span
|
||||
:aria-label="t('{p0} 선택', { p0: item.file?.name ?? item.url ?? item.text })"
|
||||
/><span>{{ item.file ? t('파일') : item.kind === 'text' ? t('텍스트') : t('링크') }}</span
|
||||
><span v-if="view === 'inbox'">{{ item.peerName }}</span
|
||||
><time>{{ date(item.createdAt) }}</time>
|
||||
</div>
|
||||
@@ -662,26 +674,29 @@ async function resendVerification() {
|
||||
}}</pre>
|
||||
<p v-else class="item-body">{{ item.url }}</p>
|
||||
<form v-if="editing === item.accessId" class="note-editor" @submit.prevent="saveNote(item)">
|
||||
<input v-model="noteDraft" maxlength="200" aria-label="한 줄 메모 수정" /><button
|
||||
<input v-model="noteDraft" maxlength="200" :aria-label="t('한 줄 메모 수정')" /><button
|
||||
class="button small"
|
||||
:disabled="busy"
|
||||
>
|
||||
저장</button
|
||||
><button type="button" class="text-button" @click="editing = null">취소</button>
|
||||
{{ t('저장') }}</button
|
||||
><button type="button" class="text-button" @click="editing = null">
|
||||
{{ t('취소') }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="item-footer">
|
||||
<span>[보관 만료] {{ expiryDate(item.expiresAt) }} 까지</span>
|
||||
<span>{{ t('[보관 만료] {p0} 까지', { p0: expiryDate(item.expiresAt) }) }}</span>
|
||||
<div>
|
||||
<button
|
||||
v-if="!item.file || fileTransfersAllowed"
|
||||
class="button small"
|
||||
@click="emit('send-item', item)"
|
||||
>
|
||||
<Send :size="14" />기기로 보내기</button
|
||||
<Send :size="14" />{{ t('기기로 보내기') }}</button
|
||||
><button v-if="item.file" class="button small" @click="act(() => download(item))">
|
||||
<Download :size="14" />다운로드</button
|
||||
<Download :size="14" />{{ t('다운로드') }}</button
|
||||
><template v-else
|
||||
><button class="button small" @click="copy(item)"><Copy :size="14" />복사</button
|
||||
><button class="button small" @click="copy(item)">
|
||||
<Copy :size="14" />{{ t('복사') }}</button
|
||||
><a
|
||||
v-if="item.url"
|
||||
class="button small"
|
||||
@@ -689,12 +704,12 @@ async function resendVerification() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="recordLinkOpen(item)"
|
||||
><ArrowUpRight :size="14" />열기</a
|
||||
><ArrowUpRight :size="14" />{{ t('열기') }}</a
|
||||
></template
|
||||
><button
|
||||
v-if="view === 'vault'"
|
||||
class="icon-button"
|
||||
aria-label="메모 수정"
|
||||
:aria-label="t('메모 수정')"
|
||||
@click="
|
||||
editing = item.accessId;
|
||||
noteDraft = item.note ?? '';
|
||||
@@ -703,7 +718,7 @@ async function resendVerification() {
|
||||
<Pencil :size="15" /></button
|
||||
><button
|
||||
class="icon-button"
|
||||
aria-label="계정 목록에서 삭제"
|
||||
:aria-label="t('계정 목록에서 삭제')"
|
||||
@click="remove([item.accessId])"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
@@ -714,19 +729,23 @@ async function resendVerification() {
|
||||
</section>
|
||||
<div v-if="!items.length" class="list-empty">
|
||||
<Inbox :size="28" />
|
||||
<h3>{{ view === 'inbox' ? '아직 받은 항목이 없어요.' : '임시 보관함이 비어 있어요.' }}</h3>
|
||||
<h3>
|
||||
{{ view === 'inbox' ? t('아직 받은 항목이 없어요.') : t('임시 보관함이 비어 있어요.') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{
|
||||
view === 'inbox'
|
||||
? '로그인한 상태로 기기에서 받으면 여기에 남습니다.'
|
||||
: '링크나 파일에 한 줄 메모를 더해 담아두세요.'
|
||||
? t('로그인한 상태로 기기에서 받으면 여기에 남습니다.')
|
||||
: t('링크나 파일에 한 줄 메모를 더해 담아두세요.')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
|
||||
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">
|
||||
{{ t('더 보기') }}
|
||||
</button>
|
||||
<p class="scope-note">
|
||||
내 보관함 <SizeLabel :bytes="storage.usedBytes" /> /
|
||||
<SizeLabel :bytes="storage.limitBytes" /> · 항목별 최대 7일 보관
|
||||
{{ t('내 보관함') }}<SizeLabel :bytes="storage.usedBytes" /> /
|
||||
<SizeLabel :bytes="storage.limitBytes" />{{ t('· 항목별 최대 7일 보관') }}
|
||||
</p>
|
||||
</section>
|
||||
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">
|
||||
@@ -734,40 +753,41 @@ async function resendVerification() {
|
||||
class="modal vault-save-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="보관함에 담기"
|
||||
:aria-label="t('보관함에 담기')"
|
||||
>
|
||||
<header>
|
||||
<h2>보관함에 담기</h2>
|
||||
<h2>{{ t('보관함에 담기') }}</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
:disabled="Boolean(saveTransfer) || busy"
|
||||
@click="compose = false"
|
||||
aria-label="보관함 저장 닫기"
|
||||
:aria-label="t('보관함 저장 닫기')"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="!fileTransfersAllowed && !saveKind" class="content-feedback" role="status">
|
||||
파일 전송이 일시 제한되어 링크와 텍스트만 저장할 수 있어요.
|
||||
{{ t('파일 전송이 일시 제한되어 링크와 텍스트만 저장할 수 있어요.') }}
|
||||
</p>
|
||||
<div v-if="!saveKind" class="save-kind-choices">
|
||||
<button class="button" @click="saveKind = 'clipboard'">
|
||||
<Copy :size="22" />클립보드 저장</button
|
||||
<Copy :size="22" />{{ t('클립보드 저장') }}</button
|
||||
><button v-if="fileTransfersAllowed" class="button" @click="saveKind = 'file'">
|
||||
<FileIcon :size="22" />파일 저장
|
||||
<FileIcon :size="22" />{{ t('파일 저장') }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<label class="vault-note-label"
|
||||
>제목 / 한 줄 메모 (선택)<input
|
||||
>{{ t('제목 / 한 줄 메모 (선택)')
|
||||
}}<input
|
||||
v-model="note"
|
||||
maxlength="200"
|
||||
aria-label="한 줄 메모"
|
||||
placeholder="예: 나중에 읽을 글"
|
||||
:aria-label="t('한 줄 메모')"
|
||||
:placeholder="t('예: 나중에 읽을 글')"
|
||||
:disabled="Boolean(saveTransfer) || busy" /></label
|
||||
><SendComposer
|
||||
:files-allowed="fileTransfersAllowed"
|
||||
target-name="임시 보관함"
|
||||
:target-name="t('임시 보관함')"
|
||||
target-key="vault-save"
|
||||
:save-mode="true"
|
||||
:content-mode="saveKind"
|
||||
@@ -789,7 +809,7 @@ async function resendVerification() {
|
||||
:disabled="Boolean(saveTransfer) || busy"
|
||||
@click="saveKind = null"
|
||||
>
|
||||
다른 종류 선택
|
||||
{{ t('다른 종류 선택') }}
|
||||
</button></template
|
||||
>
|
||||
</section>
|
||||
@@ -805,7 +825,7 @@ async function resendVerification() {
|
||||
class="modal account-preferences"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="계정 설정"
|
||||
:aria-label="t('계정 설정')"
|
||||
@keydown="preferencesKeydown"
|
||||
>
|
||||
<header>
|
||||
@@ -814,73 +834,85 @@ async function resendVerification() {
|
||||
v-if="securityAction !== 'none'"
|
||||
class="icon-button"
|
||||
@click="selectSecurityAction()"
|
||||
aria-label="계정 설정 목록으로"
|
||||
:aria-label="t('계정 설정 목록으로')"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h2>
|
||||
{{ securityAction === 'password' ? '비밀번호 변경' : '계정 설정' }}
|
||||
{{ securityAction === 'password' ? t('비밀번호 변경') : t('계정 설정') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="closePreferences" aria-label="계정 설정 닫기">
|
||||
<button
|
||||
class="icon-button"
|
||||
@click="closePreferences"
|
||||
data-close-preferences
|
||||
:aria-label="t('계정 설정 닫기')"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="securityAction === 'none'" class="preferences-list">
|
||||
<div class="preference-item">
|
||||
<div>
|
||||
<strong>이메일</strong>
|
||||
<strong>{{ t('이메일') }}</strong>
|
||||
<p>{{ account.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preference-item">
|
||||
<div>
|
||||
<strong>비밀번호</strong>
|
||||
<p>계정을 안전하게 보호하세요.</p>
|
||||
<strong>{{ t('비밀번호') }}</strong>
|
||||
<p>{{ t('계정을 안전하게 보호하세요.') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="button small"
|
||||
@click="selectSecurityAction('password')"
|
||||
aria-label="비밀번호 변경"
|
||||
:aria-label="t('비밀번호 변경')"
|
||||
>
|
||||
변경
|
||||
{{ t('변경') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form v-else class="preferences-form" @submit.prevent="securitySubmit">
|
||||
<label
|
||||
>현재 비밀번호<input
|
||||
>{{ t('현재 비밀번호')
|
||||
}}<input
|
||||
type="password"
|
||||
v-model="currentPassword"
|
||||
required
|
||||
autocomplete="current-password" /></label
|
||||
><label
|
||||
>새 비밀번호<input
|
||||
>{{ t('새 비밀번호')
|
||||
}}<input
|
||||
type="password"
|
||||
v-model="newPassword"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><label
|
||||
>새 비밀번호 확인<input
|
||||
>{{ t('새 비밀번호 확인')
|
||||
}}<input
|
||||
type="password"
|
||||
v-model="newConfirmation"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><button class="button primary" :disabled="busy">{{ busy ? '변경 중…' : '변경' }}</button>
|
||||
><button class="button primary" :disabled="busy">
|
||||
{{ busy ? t('변경 중…') : t('변경') }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="notice" class="toast" role="status"><Check :size="16" />{{ notice }}</div>
|
||||
<div v-if="notice" class="toast" role="status">
|
||||
<Check :size="16" />{{ systemMessage(notice) }}
|
||||
</div>
|
||||
<div class="undo-stack">
|
||||
<div
|
||||
v-for="operation in undo.filter((v) => v.undoUntil > now)"
|
||||
:key="operation.operationId"
|
||||
class="undo-toast"
|
||||
>
|
||||
<span>삭제됨</span
|
||||
><button @click="restore(operation)"><RotateCcw :size="16" />실행 취소</button>
|
||||
<span>{{ t('삭제됨') }}</span
|
||||
><button @click="restore(operation)"><RotateCcw :size="16" />{{ t('실행 취소') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -128,7 +128,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<section class="admin-panel" lang="ko">
|
||||
<header class="admin-heading">
|
||||
<div>
|
||||
<p class="admin-eyebrow"><ShieldCheck :size="17" />관리자</p>
|
||||
@@ -148,14 +148,14 @@ onMounted(async () => {
|
||||
<article>
|
||||
<Folder :size="21" /><span>저장 파일</span><strong>{{ overview.storedFiles }}개</strong
|
||||
><small
|
||||
><SizeLabel :bytes="overview.storedBytes" /> /
|
||||
<SizeLabel :bytes="overview.settings.totalStorageLimitMb * 1048576"
|
||||
><SizeLabel language="ko" :bytes="overview.storedBytes" /> /
|
||||
<SizeLabel language="ko" :bytes="overview.settings.totalStorageLimitMb * 1048576"
|
||||
/></small>
|
||||
</article>
|
||||
<article>
|
||||
<HardDrive :size="21" /><span>디스크 여유</span
|
||||
><strong><SizeLabel :bytes="overview.diskFreeBytes" /></strong
|
||||
><small>업로드 예약 <SizeLabel :bytes="overview.reservedBytes" /></small>
|
||||
><strong><SizeLabel language="ko" :bytes="overview.diskFreeBytes" /></strong
|
||||
><small>업로드 예약 <SizeLabel language="ko" :bytes="overview.reservedBytes" /></small>
|
||||
</article>
|
||||
<article>
|
||||
<RefreshCw :size="21" /><span>업로드 처리</span
|
||||
@@ -202,7 +202,7 @@ onMounted(async () => {
|
||||
<article v-for="file in files" :key="file.id" class="admin-file">
|
||||
<div class="admin-file-heading">
|
||||
<strong>{{ file.name }}</strong
|
||||
><span><SizeLabel :bytes="file.size" /></span>
|
||||
><span><SizeLabel language="ko" :bytes="file.size" /></span>
|
||||
</div>
|
||||
<div class="admin-file-info admin-file-owner">
|
||||
<code>{{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { t, intlLanguage, systemMessage } from './i18n';
|
||||
import LanguagePicker from './LanguagePicker.vue';
|
||||
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted, provide } from 'vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowRight,
|
||||
@@ -102,6 +105,8 @@ const tab = ref('devices'),
|
||||
invite = ref(null),
|
||||
qr = ref(''),
|
||||
now = ref(Date.now());
|
||||
provide('hop-notify', notify);
|
||||
let noticeTimer;
|
||||
const blockedDevices = ref([]),
|
||||
confirmBlockId = ref(null);
|
||||
const retrySeconds = computed(() =>
|
||||
@@ -117,13 +122,13 @@ const modalTitle = computed(() =>
|
||||
modal.value === 'connect'
|
||||
? connectTitle.value
|
||||
: modal.value === 'about'
|
||||
? '서비스 정보'
|
||||
? t('서비스 정보')
|
||||
: modal.value === 'blocks'
|
||||
? '차단한 기기'
|
||||
: '내 기기 설정',
|
||||
? t('차단한 기기')
|
||||
: t('내 기기 설정'),
|
||||
);
|
||||
function contentLabel(kind) {
|
||||
return kind === 'file' ? '파일' : kind === 'text' ? '텍스트' : '링크';
|
||||
return kind === 'file' ? t('파일') : kind === 'text' ? t('텍스트') : t('링크');
|
||||
}
|
||||
|
||||
const normalizedJoinCode = computed(() => joinCode.value.replace(/[\s-]/g, '').toUpperCase());
|
||||
@@ -132,10 +137,10 @@ const validJoinCode = computed(() =>
|
||||
);
|
||||
const connectTitle = computed(() =>
|
||||
connectView.value === 'join'
|
||||
? '연결 공간 참여'
|
||||
? t('연결 공간 참여')
|
||||
: connectView.value === 'choose'
|
||||
? '다른 기기 연결'
|
||||
: '연결 공간 만들기',
|
||||
? t('다른 기기 연결')
|
||||
: t('연결 공간 만들기'),
|
||||
);
|
||||
watch(modal, (value) => {
|
||||
if (value === 'connect') {
|
||||
@@ -189,7 +194,7 @@ async function prepareTransfer(target) {
|
||||
) {
|
||||
if (!(await nearbySearch.ensureReady()))
|
||||
throw Error(
|
||||
'기기 연결을 복구하지 못했어요. 다시 시도하거나 다른 기기 연결을 이용해 주세요.',
|
||||
t('기기 연결을 복구하지 못했어요. 다시 시도하거나 다른 기기 연결을 이용해 주세요.'),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -238,7 +243,7 @@ async function receiveAction(r, action) {
|
||||
await requestAction(r.id, action);
|
||||
if (action === 'block') {
|
||||
confirmBlockId.value = null;
|
||||
notify('기기를 차단했어요. 내 기기 설정에서 해제할 수 있어요.');
|
||||
notify(t('기기를 차단했어요. 내 기기 설정에서 해제할 수 있어요.'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (action === 'accept') receiver.abort(r.id);
|
||||
@@ -454,7 +459,9 @@ watch([requests, latestRequestedTransfer], ([current, requestedId], [previous])
|
||||
itemToSend.value = null;
|
||||
forwardedItem.value = null;
|
||||
}
|
||||
notify(`${request.recipientDevice?.name || '상대방'}에서 전송을 거절했어요.`);
|
||||
notify(
|
||||
t('{p0}에서 전송을 거절했어요.', { p0: request.recipientDevice?.name || t('상대방') }),
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.direction === 'sent' &&
|
||||
@@ -465,7 +472,7 @@ watch([requests, latestRequestedTransfer], ([current, requestedId], [previous])
|
||||
sendOpen.value = false;
|
||||
selected.value = null;
|
||||
itemToSend.value = null;
|
||||
notify('전송했습니다.');
|
||||
notify(t('전송했습니다.'));
|
||||
}
|
||||
}
|
||||
const visible = new Set(current.map((request) => request.id));
|
||||
@@ -485,13 +492,13 @@ watch(spaces, (current, previous) => {
|
||||
)
|
||||
) {
|
||||
if (modal.value === 'connect') modal.value = null;
|
||||
notify('기기가 방에 참여했습니다.');
|
||||
notify(t('기기가 방에 참여했습니다.'));
|
||||
}
|
||||
if (!current.length && previous.length) {
|
||||
selected.value = null;
|
||||
invite.value = null;
|
||||
modal.value = null;
|
||||
notify('방에서 나왔습니다.');
|
||||
notify(t('방에서 나왔습니다.'));
|
||||
}
|
||||
});
|
||||
const vaultItems = ref([]),
|
||||
@@ -537,7 +544,7 @@ watch(fileTransfersAllowed, (allowed) => {
|
||||
});
|
||||
function prepareVault(item) {
|
||||
if (item.file && !fileTransfersAllowed.value) {
|
||||
showError(Error('지금은 링크와 텍스트만 보낼 수 있어요.'));
|
||||
showError(Error(t('지금은 링크와 텍스트만 보낼 수 있어요.')));
|
||||
return;
|
||||
}
|
||||
itemToSend.value = item;
|
||||
@@ -545,17 +552,17 @@ function prepareVault(item) {
|
||||
sendOpen.value = false;
|
||||
forwardedItem.value = null;
|
||||
tab.value = 'devices';
|
||||
notify('받을 기기를 선택해 주세요.');
|
||||
notify(t('받을 기기를 선택해 주세요.'));
|
||||
}
|
||||
async function forwardVault(item) {
|
||||
if (item.file && !fileTransfersAllowed.value) {
|
||||
showError(Error('지금은 링크와 텍스트만 보낼 수 있어요.'));
|
||||
showError(Error(t('지금은 링크와 텍스트만 보낼 수 있어요.')));
|
||||
return;
|
||||
}
|
||||
if (!selected.value) {
|
||||
itemToSend.value = item;
|
||||
tab.value = 'devices';
|
||||
notify('받을 기기를 선택해 주세요.');
|
||||
notify(t('받을 기기를 선택해 주세요.'));
|
||||
return;
|
||||
}
|
||||
const owner = account.value?.id;
|
||||
@@ -605,7 +612,7 @@ onMounted(async () => {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
await api('/spaces/join', 'POST', { token });
|
||||
await refresh();
|
||||
notify('연결 공간에 참여했습니다.');
|
||||
notify(t('연결 공간에 참여했습니다.'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
@@ -615,7 +622,7 @@ onMounted(async () => {
|
||||
});
|
||||
const onFocus = () => void refresh().catch(showError);
|
||||
const onReceiptSkipped = () =>
|
||||
notify('보관함 용량이 부족해 받은 항목에 저장하지 않았어요. 파일 다운로드는 진행됩니다.');
|
||||
notify(t('보관함 용량이 부족해 받은 항목에 저장하지 않았어요. 파일 다운로드는 진행됩니다.'));
|
||||
window.addEventListener('hop-receipt-skipped', onReceiptSkipped);
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') onFocus();
|
||||
@@ -628,11 +635,13 @@ onUnmounted(() => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
clearInterval(timer);
|
||||
clearTimeout(noticeTimer);
|
||||
media.removeEventListener('change', applyTheme);
|
||||
});
|
||||
function notify(message) {
|
||||
clearTimeout(noticeTimer);
|
||||
notice.value = message;
|
||||
setTimeout(() => {
|
||||
noticeTimer = setTimeout(() => {
|
||||
if (notice.value === message) notice.value = '';
|
||||
}, 4000);
|
||||
}
|
||||
@@ -666,7 +675,7 @@ async function unblockDevice(peer) {
|
||||
await api(`/device/blocks/${peer.deviceId}`, 'DELETE');
|
||||
blockedDevices.value = (await api('/device/blocks')).blocks;
|
||||
await refresh();
|
||||
notify('차단을 해제했어요.');
|
||||
notify(t('차단을 해제했어요.'));
|
||||
});
|
||||
}
|
||||
async function saveName() {
|
||||
@@ -674,7 +683,7 @@ async function saveName() {
|
||||
await patchDevice({ name: nameDraft.value });
|
||||
editName.value = false;
|
||||
modal.value = null;
|
||||
notify('기기 이름을 변경했습니다.');
|
||||
notify(t('기기 이름을 변경했습니다.'));
|
||||
});
|
||||
}
|
||||
function selectPeer(peer) {
|
||||
@@ -686,7 +695,7 @@ function selectPeer(peer) {
|
||||
}
|
||||
async function submitClipboard(content) {
|
||||
await act(async () => {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
if (!selected.value) throw Error(t('받을 기기를 선택해 주세요.'));
|
||||
const target = selected.value;
|
||||
await prepareTransfer(target);
|
||||
forwardedItem.value = null;
|
||||
@@ -704,9 +713,9 @@ async function copyReceived(item) {
|
||||
if (!navigator.clipboard?.writeText) throw Error('unavailable');
|
||||
await navigator.clipboard.writeText(item.kind === 'text' ? item.text : item.url);
|
||||
} catch {
|
||||
throw Error('복사하지 못했어요. 내용을 선택해 직접 복사해 주세요.');
|
||||
throw Error(t('복사하지 못했어요. 내용을 선택해 직접 복사해 주세요.'));
|
||||
}
|
||||
notify('클립보드에 복사했어요.');
|
||||
notify(t('클립보드에 복사했어요.'));
|
||||
await markRead(item);
|
||||
});
|
||||
}
|
||||
@@ -716,9 +725,9 @@ async function closeReceivedText(item) {
|
||||
}
|
||||
async function submitFiles(files) {
|
||||
await act(async () => {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
if (!selected.value) throw Error(t('받을 기기를 선택해 주세요.'));
|
||||
if (!fileTransfersAllowed.value && currentTransfer.value?.status !== 'accepted')
|
||||
throw Error('지금은 링크와 텍스트만 보낼 수 있어요.');
|
||||
throw Error(t('지금은 링크와 텍스트만 보낼 수 있어요.'));
|
||||
const target = selected.value;
|
||||
forwardedItem.value = null;
|
||||
itemToSend.value = null;
|
||||
@@ -749,7 +758,7 @@ async function joinSpace() {
|
||||
joinCode.value = '';
|
||||
await refresh();
|
||||
modal.value = null;
|
||||
notify('방에 참여했습니다.');
|
||||
notify(t('방에 참여했습니다.'));
|
||||
});
|
||||
}
|
||||
async function copyValue(value, item) {
|
||||
@@ -757,10 +766,10 @@ async function copyValue(value, item) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
throw Error('복사하지 못했습니다. 내용을 선택해 직접 복사해 주세요.');
|
||||
throw Error(t('복사하지 못했습니다. 내용을 선택해 직접 복사해 주세요.'));
|
||||
}
|
||||
if (item) await markRead(item);
|
||||
notify('복사했습니다.');
|
||||
notify(t('복사했습니다.'));
|
||||
});
|
||||
}
|
||||
function iconFor(name) {
|
||||
@@ -771,7 +780,7 @@ function iconFor(name) {
|
||||
: Monitor;
|
||||
}
|
||||
function time(value) {
|
||||
return new Intl.DateTimeFormat('ko', {
|
||||
return new Intl.DateTimeFormat(intlLanguage(), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
@@ -792,7 +801,7 @@ async function spaceAction(space, action, memberId) {
|
||||
if (memberId && action === 'remove') {
|
||||
if (selected.value?.id === memberId && !peers.value.some((p) => p.id === memberId))
|
||||
selected.value = null;
|
||||
notify('연결 공간에서 내보냈습니다.');
|
||||
notify(t('연결 공간에서 내보냈습니다.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -802,20 +811,21 @@ async function spaceAction(space, action, memberId) {
|
||||
<div class="app-shell" :data-online="connected">
|
||||
<header class="site-header">
|
||||
<div class="header-brand">
|
||||
<button class="wordmark" @click="tab = 'devices'" aria-label="hop. 홈">
|
||||
<button class="wordmark" @click="tab = 'devices'" :aria-label="t('hop. 홈')">
|
||||
<LogoMark />
|
||||
</button>
|
||||
<span class="header-tagline">기기 사이를 가볍게.</span>
|
||||
<span class="header-tagline">{{ t('기기 사이를 가볍게.') }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<LanguagePicker />
|
||||
<button class="login-button" @click="tab = 'vault'">
|
||||
{{ account ? '보관함' : '로그인' }} <ArrowUpRight :size="15" />
|
||||
{{ account ? t('보관함') : t('로그인') }} <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-label="t('더보기')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="headerMore"
|
||||
aria-controls="header-more-menu"
|
||||
@@ -829,7 +839,7 @@ async function spaceAction(space, action, memberId) {
|
||||
id="header-more-menu"
|
||||
class="header-more-menu"
|
||||
role="menu"
|
||||
aria-label="더보기"
|
||||
:aria-label="t('더보기')"
|
||||
@keydown="headerMenuKey"
|
||||
>
|
||||
<InstallButton
|
||||
@@ -840,15 +850,15 @@ async function spaceAction(space, action, memberId) {
|
||||
<button
|
||||
class="theme-toggle"
|
||||
role="menuitem"
|
||||
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
|
||||
:aria-label="theme === 'light' ? t('다크 테마로 전환') : t('라이트 테마로 전환')"
|
||||
@click="changeTheme"
|
||||
>
|
||||
<Moon v-if="theme === 'light'" :size="18" /><Sun v-else :size="18" /><span>{{
|
||||
theme === 'light' ? '다크 테마' : '라이트 테마'
|
||||
theme === 'light' ? t('다크 테마') : t('라이트 테마')
|
||||
}}</span>
|
||||
</button>
|
||||
<button role="menuitem" @click="openAbout">
|
||||
<Info :size="18" /><span>서비스 정보</span>
|
||||
<Info :size="18" /><span>{{ t('서비스 정보') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="account?.administrator"
|
||||
@@ -858,7 +868,7 @@ async function spaceAction(space, action, memberId) {
|
||||
closeHeaderMore();
|
||||
"
|
||||
>
|
||||
<ShieldCheck :size="18" /><span>관리자</span>
|
||||
<ShieldCheck :size="18" /><span>{{ t('관리자') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -866,23 +876,23 @@ async function spaceAction(space, action, memberId) {
|
||||
</header>
|
||||
<main>
|
||||
<p v-if="!connected && !loading" class="reconnect-note" role="status">
|
||||
연결을 복구하고 있어요.
|
||||
{{ t('연결을 복구하고 있어요.') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="initial-device-loading"
|
||||
role="status"
|
||||
aria-label="연결 정보 확인 중"
|
||||
:aria-label="t('연결 정보 확인 중')"
|
||||
>
|
||||
<div class="peer-card peer-skeleton"><i></i><span></span><small></small></div>
|
||||
<p>연결 정보를 확인하고 있어요.</p>
|
||||
<p>{{ t('연결 정보를 확인하고 있어요.') }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Teleport to="body">
|
||||
<section
|
||||
v-if="receiving.length || blockedLinks.length"
|
||||
class="incoming-section"
|
||||
aria-label="수신 알림"
|
||||
:aria-label="t('수신 알림')"
|
||||
aria-live="polite"
|
||||
>
|
||||
<article
|
||||
@@ -892,12 +902,12 @@ async function spaceAction(space, action, memberId) {
|
||||
>
|
||||
<span class="incoming-icon"><LoaderCircle class="spin" :size="20" /></span>
|
||||
<div class="incoming-info">
|
||||
<strong>{{ r.senderDevice?.name }}에서 받는 중</strong>
|
||||
<strong>{{ t('{p0}에서 받는 중', { p0: r.senderDevice?.name }) }}</strong>
|
||||
<p>
|
||||
{{
|
||||
r.transferStatus === 'uploading'
|
||||
? '파일을 업로드하고 있어요.'
|
||||
: '수락했습니다. 발신 기기에서 전달을 준비하고 있어요.'
|
||||
? t('파일을 업로드하고 있어요.')
|
||||
: t('수락했습니다. 발신 기기에서 전달을 준비하고 있어요.')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -908,8 +918,8 @@ async function spaceAction(space, action, memberId) {
|
||||
class="incoming-card delivery-blocked"
|
||||
>
|
||||
<div class="incoming-info">
|
||||
<strong>브라우저가 새 탭 열기를 제한했어요.</strong>
|
||||
<p>아래 링크를 누르면 바로 열립니다.</p>
|
||||
<strong>{{ t('브라우저가 새 탭 열기를 제한했어요.') }}</strong>
|
||||
<p>{{ t('아래 링크를 누르면 바로 열립니다.') }}</p>
|
||||
</div>
|
||||
<a
|
||||
class="button primary small"
|
||||
@@ -917,11 +927,13 @@ async function spaceAction(space, action, memberId) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="receiver.opened(item)"
|
||||
>링크 열기 <ArrowUpRight :size="15" /></a
|
||||
><button class="button small" @click="copyReceived(item)">주소 복사</button>
|
||||
>{{ t('링크 열기') }}<ArrowUpRight :size="15" /></a
|
||||
><button class="button small" @click="copyReceived(item)">
|
||||
{{ t('주소 복사') }}
|
||||
</button>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="안내 닫기"
|
||||
:aria-label="t('안내 닫기')"
|
||||
@click="blockedLinks = blockedLinks.filter((i) => i.accessId !== item.accessId)"
|
||||
>
|
||||
<X :size="16" />
|
||||
@@ -937,22 +949,24 @@ async function spaceAction(space, action, memberId) {
|
||||
class="received-text-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="받은 텍스트"
|
||||
:aria-label="t('받은 텍스트')"
|
||||
@keydown.tab="trapReceivedFocus"
|
||||
>
|
||||
<header>
|
||||
<h2>
|
||||
<ClipboardPaste :size="20" />{{ receivedTexts[0].peerName }}에서 텍스트를 보냈어요
|
||||
<ClipboardPaste :size="20" />{{
|
||||
t('{p0}에서 텍스트를 보냈어요', { p0: receivedTexts[0].peerName })
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="받은 텍스트 닫기"
|
||||
:aria-label="t('받은 텍스트 닫기')"
|
||||
@click="closeReceivedText(receivedTexts[0])"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<pre class="received-text-preview" tabindex="0" aria-label="받은 텍스트 내용">{{
|
||||
<pre class="received-text-preview" tabindex="0" :aria-label="t('받은 텍스트 내용')">{{
|
||||
receivedTexts[0].text
|
||||
}}</pre>
|
||||
<button
|
||||
@@ -960,7 +974,7 @@ async function spaceAction(space, action, memberId) {
|
||||
:disabled="busy"
|
||||
@click="copyReceived(receivedTexts[0])"
|
||||
>
|
||||
<Copy :size="18" />클립보드에 복사
|
||||
<Copy :size="18" />{{ t('클립보드에 복사') }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -975,11 +989,12 @@ async function spaceAction(space, action, memberId) {
|
||||
class="received-link-notice"
|
||||
role="status"
|
||||
>
|
||||
<Link :size="18" /><span>링크를 열었어요.</span
|
||||
><button class="text-button" @click="copyReceived(receivedLinks[0])">주소 복사</button
|
||||
<Link :size="18" /><span>{{ t('링크를 열었어요.') }}</span
|
||||
><button class="text-button" @click="copyReceived(receivedLinks[0])">
|
||||
{{ t('주소 복사') }}</button
|
||||
><button
|
||||
class="icon-button"
|
||||
aria-label="링크 수신 안내 닫기"
|
||||
:aria-label="t('링크 수신 안내 닫기')"
|
||||
@click="receivedLinks = []"
|
||||
>
|
||||
<X :size="16" />
|
||||
@@ -992,7 +1007,7 @@ async function spaceAction(space, action, memberId) {
|
||||
class="incoming-card consent-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="전송 수락 요청"
|
||||
:aria-label="t('전송 수락 요청')"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="consent-heading">
|
||||
@@ -1003,22 +1018,27 @@ async function spaceAction(space, action, memberId) {
|
||||
/><Link v-else :size="28" />
|
||||
</div>
|
||||
<div class="incoming-info">
|
||||
<h2 class="consent-kind">{{ contentLabel(r.kind) }} 전송 요청</h2>
|
||||
<h2 class="consent-kind">
|
||||
{{ t('{p0} 전송 요청', { p0: contentLabel(r.kind) }) }}
|
||||
</h2>
|
||||
<strong
|
||||
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
|
||||
>
|
||||
<p>
|
||||
<template v-if="r.kind === 'file'"
|
||||
>{{ r.count }}개 ·
|
||||
<template v-if="r.count > 1"
|
||||
>총 <SizeLabel :bytes="r.byteSize" /> ·
|
||||
>{{ t('{p0}개 ·', { p0: r.count })
|
||||
}}<template v-if="r.count > 1"
|
||||
>{{ t('총') }}<SizeLabel :bytes="r.byteSize" /> ·
|
||||
</template></template
|
||||
>
|
||||
<span
|
||||
>{{
|
||||
Math.max(0, Math.ceil((r.expiresAt - Math.max(now, Date.now())) / 1000))
|
||||
}}초 남음</span
|
||||
>
|
||||
<span>{{
|
||||
t('{p0}초 남음', {
|
||||
p0: Math.max(
|
||||
0,
|
||||
Math.ceil((r.expiresAt - Math.max(now, Date.now())) / 1000),
|
||||
),
|
||||
})
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1033,7 +1053,7 @@ async function spaceAction(space, action, memberId) {
|
||||
class="content-feedback"
|
||||
role="status"
|
||||
>
|
||||
파일 전송이 일시 제한되어 수락할 수 없어요.
|
||||
{{ t('파일 전송이 일시 제한되어 수락할 수 없어요.') }}
|
||||
</p>
|
||||
<div class="incoming-actions">
|
||||
<button
|
||||
@@ -1041,22 +1061,22 @@ async function spaceAction(space, action, memberId) {
|
||||
:disabled="busy || (r.kind === 'file' && !fileTransfersAllowed)"
|
||||
@click="receiveAction(r, 'accept')"
|
||||
>
|
||||
<Check :size="16" /> 수락</button
|
||||
<Check :size="16" />{{ t('수락') }}</button
|
||||
><button class="button small" :disabled="busy" @click="receiveAction(r, 'reject')">
|
||||
거절
|
||||
{{ t('거절') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="consent-footer">
|
||||
<template v-if="confirmBlockId === r.id"
|
||||
><span>이 기기를 차단할까요?</span
|
||||
><span>{{ t('이 기기를 차단할까요?') }}</span
|
||||
><button class="text-button" :disabled="busy" @click="confirmBlockId = null">
|
||||
취소</button
|
||||
{{ t('취소') }}</button
|
||||
><button
|
||||
class="text-button block-confirm"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'block')"
|
||||
>
|
||||
차단하기
|
||||
{{ t('차단하기') }}
|
||||
</button></template
|
||||
>
|
||||
<button
|
||||
@@ -1065,7 +1085,7 @@ async function spaceAction(space, action, memberId) {
|
||||
:disabled="busy"
|
||||
@click="confirmBlockId = r.id"
|
||||
>
|
||||
이 기기 차단
|
||||
{{ t('이 기기 차단') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1075,19 +1095,19 @@ async function spaceAction(space, action, memberId) {
|
||||
<section class="devices-stage">
|
||||
<section class="nearby-section">
|
||||
<p v-if="!fileTransfersAllowed" class="file-policy-note" role="status">
|
||||
파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.
|
||||
{{ t('파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.') }}
|
||||
</p>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>
|
||||
{{ spaces.length ? '방 안의 기기' : '주변 기기' }}
|
||||
{{ spaces.length ? t('방 안의 기기') : t('주변 기기') }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!connectionCards.length" class="empty-devices">
|
||||
<Monitor :size="30" stroke-width="1.4" />
|
||||
<h3>아직 연결된 기기가 없어요.</h3>
|
||||
<p>다른 기기에서 hop.을 열거나 QR로 연결해 보세요.</p>
|
||||
<h3>{{ t('아직 연결된 기기가 없어요.') }}</h3>
|
||||
<p>{{ t('다른 기기에서 hop.을 열거나 QR로 연결해 보세요.') }}</p>
|
||||
</div>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
@@ -1107,7 +1127,7 @@ async function spaceAction(space, action, memberId) {
|
||||
v-if="card.skeleton"
|
||||
class="peer-card peer-skeleton"
|
||||
role="status"
|
||||
aria-label="연결 정보 확인 중"
|
||||
:aria-label="t('연결 정보 확인 중')"
|
||||
>
|
||||
<i></i><span></span><small></small>
|
||||
</div>
|
||||
@@ -1123,7 +1143,7 @@ async function spaceAction(space, action, memberId) {
|
||||
v-if="card.pending"
|
||||
class="peer-live"
|
||||
:class="{ hasMenu: managedSpaces(card.peer.id).length }"
|
||||
>승인 대기</span
|
||||
>{{ t('승인 대기') }}</span
|
||||
></span
|
||||
><strong>{{ deviceLabel(card.peer) }}</strong
|
||||
><span v-if="deviceDetail(card.peer)" class="peer-model">{{
|
||||
@@ -1134,10 +1154,10 @@ async function spaceAction(space, action, memberId) {
|
||||
><span
|
||||
>{{
|
||||
card.peer.sources.includes('network')
|
||||
? '같은 네트워크'
|
||||
? t('같은 네트워크')
|
||||
: card.peer.sources.includes('nearby')
|
||||
? '근처 기기'
|
||||
: '연결 공간'
|
||||
? t('근처 기기')
|
||||
: t('연결 공간')
|
||||
}}
|
||||
<span v-if="isHost(card.peer.id)" class="host-badge">HOST</span></span
|
||||
><span v-if="!card.pending" class="peer-select"
|
||||
@@ -1149,7 +1169,7 @@ async function spaceAction(space, action, memberId) {
|
||||
v-if="!card.skeleton && managedSpaces(card.peer.id).length"
|
||||
:id="`device-menu-toggle-${card.peer.id}`"
|
||||
class="icon-button device-menu-toggle"
|
||||
:aria-label="`${card.peer.name} 기기 더보기`"
|
||||
:aria-label="t('{p0} 기기 더보기', { p0: card.peer.name })"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="deviceMenu === card.peer.id"
|
||||
:aria-controls="`device-menu-${card.peer.id}`"
|
||||
@@ -1162,7 +1182,7 @@ async function spaceAction(space, action, memberId) {
|
||||
:id="`device-menu-${card.peer.id}`"
|
||||
class="device-menu"
|
||||
role="menu"
|
||||
:aria-label="`${card.peer.name} 기기 관리`"
|
||||
:aria-label="t('{p0} 기기 관리', { p0: card.peer.name })"
|
||||
>
|
||||
<template v-for="space in managedSpaces(card.peer.id)" :key="space.id"
|
||||
><button
|
||||
@@ -1175,16 +1195,19 @@ async function spaceAction(space, action, memberId) {
|
||||
:disabled="busy"
|
||||
@click="spaceAction(space, 'approve', card.peer.id)"
|
||||
>
|
||||
참여 승인</button
|
||||
{{ t('참여 승인') }}</button
|
||||
><button
|
||||
role="menuitem"
|
||||
class="kick-action"
|
||||
:disabled="busy"
|
||||
@click="spaceAction(space, 'remove', card.peer.id)"
|
||||
>
|
||||
내보내기<span v-if="managedSpaces(card.peer.id).length > 1">
|
||||
· 연결 공간 {{ spaces.findIndex((s) => s.id === space.id) + 1 }}</span
|
||||
>
|
||||
{{ t('내보내기')
|
||||
}}<span v-if="managedSpaces(card.peer.id).length > 1">{{
|
||||
t('· 연결 공간 {p0}', {
|
||||
p0: spaces.findIndex((s) => s.id === space.id) + 1,
|
||||
})
|
||||
}}</span>
|
||||
</button></template
|
||||
>
|
||||
</div>
|
||||
@@ -1193,13 +1216,13 @@ async function spaceAction(space, action, memberId) {
|
||||
v-if="canInvite"
|
||||
key="qr-connect"
|
||||
class="qr-connect-card"
|
||||
:aria-label="spaces.length ? '초대 보기' : '다른 기기 연결'"
|
||||
:aria-label="spaces.length ? t('초대 보기') : t('다른 기기 연결')"
|
||||
@click="modal = 'connect'"
|
||||
>
|
||||
<QrCode :size="32" /><strong>{{
|
||||
spaces.length ? '초대 보기' : '다른 기기 연결'
|
||||
spaces.length ? t('초대 보기') : t('다른 기기 연결')
|
||||
}}</strong
|
||||
><span>QR 또는 코드</span>
|
||||
><span>{{ t('QR 또는 코드') }}</span>
|
||||
</button>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
@@ -1215,7 +1238,7 @@ async function spaceAction(space, action, memberId) {
|
||||
)
|
||||
"
|
||||
>
|
||||
방 나가기
|
||||
{{ t('방 나가기') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1233,7 +1256,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<div class="my-device-info" v-if="device">
|
||||
<button
|
||||
class="device-name footer-device-summary"
|
||||
aria-label="내 기기 설정"
|
||||
:aria-label="t('내 기기 설정')"
|
||||
@click="openDeviceSettings"
|
||||
>
|
||||
<i class="device-state-dot" :class="{ enabled: device.discoverable }"></i
|
||||
@@ -1256,16 +1279,16 @@ async function spaceAction(space, action, memberId) {
|
||||
class="modal send-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="`${deviceLabel(selected)}에 보내기`"
|
||||
:aria-label="t('{p0}에 보내기', { p0: deviceLabel(selected) })"
|
||||
@keydown.tab="trapSendFocus"
|
||||
>
|
||||
<header>
|
||||
<h2>{{ deviceLabel(selected) }}에 보내기</h2>
|
||||
<h2>{{ t('{p0}에 보내기', { p0: deviceLabel(selected) }) }}</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
:disabled="busy"
|
||||
@click="closeSend"
|
||||
aria-label="전송 화면 닫기"
|
||||
:aria-label="t('전송 화면 닫기')"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
@@ -1303,10 +1326,12 @@ async function spaceAction(space, action, memberId) {
|
||||
</SendComposer>
|
||||
</section></div
|
||||
></Teleport>
|
||||
<div v-if="notice" class="toast" role="status"><Check :size="17" />{{ notice }}</div>
|
||||
<div v-if="notice" class="toast" role="status">
|
||||
<Check :size="17" />{{ systemMessage(notice) }}
|
||||
</div>
|
||||
<div v-if="error" class="error-toast" role="alert">
|
||||
<span>{{ error }}</span
|
||||
><button class="icon-button" @click="error = ''" aria-label="오류 닫기">
|
||||
<span>{{ systemMessage(error) }}</span
|
||||
><button class="icon-button" @click="error = ''" :aria-label="t('오류 닫기')">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -1319,7 +1344,7 @@ async function spaceAction(space, action, memberId) {
|
||||
class="icon-button"
|
||||
:disabled="busy"
|
||||
@click="connectView = 'choose'"
|
||||
aria-label="연결 방법 선택으로 돌아가기"
|
||||
:aria-label="t('연결 방법 선택으로 돌아가기')"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
@@ -1327,28 +1352,31 @@ async function spaceAction(space, action, memberId) {
|
||||
{{ modalTitle }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
|
||||
<button class="icon-button" @click="modal = null" :aria-label="t('대화상자 닫기')">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<template v-if="modal === 'connect'">
|
||||
<template v-if="connectView === 'choose'">
|
||||
<p class="modal-subtitle">서로 다른 네트워크에서도 기기를 연결할 수 있어요.</p>
|
||||
<p class="modal-subtitle">
|
||||
{{ t('서로 다른 네트워크에서도 기기를 연결할 수 있어요.') }}
|
||||
</p>
|
||||
<div class="connection-choices">
|
||||
<button
|
||||
class="connection-choice"
|
||||
aria-label="연결 공간 만들기"
|
||||
:aria-label="t('연결 공간 만들기')"
|
||||
@click="connectView = 'create'"
|
||||
>
|
||||
<QrCode :size="30" /><strong>연결 공간 만들기</strong
|
||||
><span>QR이나 코드로 초대</span>
|
||||
<QrCode :size="30" /><strong>{{ t('연결 공간 만들기') }}</strong
|
||||
><span>{{ t('QR이나 코드로 초대') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="connection-choice"
|
||||
aria-label="연결 공간 참여"
|
||||
:aria-label="t('연결 공간 참여')"
|
||||
@click="connectView = 'join'"
|
||||
>
|
||||
<LogIn :size="30" /><strong>연결 공간 참여</strong><span>초대 코드로 참여</span>
|
||||
<LogIn :size="30" /><strong>{{ t('연결 공간 참여') }}</strong
|
||||
><span>{{ t('초대 코드로 참여') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1357,70 +1385,76 @@ async function spaceAction(space, action, memberId) {
|
||||
class="create-space"
|
||||
@submit.prevent="createSpace"
|
||||
>
|
||||
<p class="modal-subtitle">QR이나 초대 코드로 기기를 초대하세요.</p>
|
||||
<p class="modal-subtitle">{{ t('QR이나 초대 코드로 기기를 초대하세요.') }}</p>
|
||||
<label class="checkbox-label"
|
||||
><input type="checkbox" v-model="approvalRequired" />참여할 때 내 승인 받기</label
|
||||
><input type="checkbox" v-model="approvalRequired" />{{
|
||||
t('참여할 때 내 승인 받기')
|
||||
}}</label
|
||||
>
|
||||
<button class="button primary" type="submit" :disabled="busy">
|
||||
만들기 <ArrowRight :size="16" />
|
||||
{{ t('만들기') }}<ArrowRight :size="16" />
|
||||
</button>
|
||||
</form>
|
||||
<form v-else-if="connectView === 'join'" class="join-form" @submit.prevent="joinSpace">
|
||||
<p class="modal-subtitle">상대방에게 받은 초대 코드를 입력하세요.</p>
|
||||
<p class="modal-subtitle">{{ t('상대방에게 받은 초대 코드를 입력하세요.') }}</p>
|
||||
<input
|
||||
v-model="joinCode"
|
||||
aria-label="연결 코드"
|
||||
:placeholder="`초대 코드 ${spaceInviteCodeLength}자리`"
|
||||
:aria-label="t('연결 코드')"
|
||||
:placeholder="t('초대 코드 {p0}자리', { p0: spaceInviteCodeLength })"
|
||||
maxlength="10"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button class="button primary" type="submit" :disabled="busy || !validJoinCode">
|
||||
참여하기 <ArrowRight :size="16" />
|
||||
{{ t('참여하기') }}<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" />
|
||||
<img :src="qr" :alt="t('연결 공간 초대 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)) }}분 동안 유효
|
||||
{{
|
||||
t('{p0}분 동안 유효', {
|
||||
p0: 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" />코드 복사
|
||||
<Copy :size="16" />{{ t('코드 복사') }}
|
||||
</button>
|
||||
<p class="invite-waiting">다른 기기의 참여를 기다리고 있어요.</p>
|
||||
<p class="invite-waiting">{{ t('다른 기기의 참여를 기다리고 있어요.') }}</p>
|
||||
</div>
|
||||
<p v-else class="modal-subtitle">초대가 만료됐어요.</p>
|
||||
<p v-else class="modal-subtitle">{{ t('초대가 만료됐어요.') }}</p>
|
||||
</template>
|
||||
<template v-else-if="modal === 'about'">
|
||||
<div class="service-info">
|
||||
<LogoMark class="service-brand" label="hop." />
|
||||
<p class="service-tagline">기기 사이를 가볍게.</p>
|
||||
<p class="service-tagline">{{ t('기기 사이를 가볍게.') }}</p>
|
||||
<div class="service-intro">
|
||||
<p>Android, iOS, macOS, Windows.</p>
|
||||
<p>서로 다른 운영체제에서도 파일과 링크를 자유롭게 주고받으세요.</p>
|
||||
<p>{{ t('서로 다른 운영체제에서도 파일과 링크를 자유롭게 주고받으세요.') }}</p>
|
||||
</div>
|
||||
<ul class="service-features">
|
||||
<li>
|
||||
<MonitorSmartphone :size="20" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>기기 구분 없이</strong>
|
||||
<p>스마트폰과 태블릿, PC를 자유롭게 연결</p>
|
||||
<strong>{{ t('기기 구분 없이') }}</strong>
|
||||
<p>{{ t('스마트폰과 태블릿, PC를 자유롭게 연결') }}</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<Globe :size="20" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>설치 없이 간편하게</strong>
|
||||
<p>회원가입 없이 브라우저에서 바로 전송</p>
|
||||
<strong>{{ t('설치 없이 간편하게') }}</strong>
|
||||
<p>{{ t('회원가입 없이 브라우저에서 바로 전송') }}</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<QrCode :size="20" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>네트워크가 달라도</strong>
|
||||
<p>QR 또는 초대 코드로 다른 기기와 연결</p>
|
||||
<strong>{{ t('네트워크가 달라도') }}</strong>
|
||||
<p>{{ t('QR 또는 초대 코드로 다른 기기와 연결') }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1430,7 +1464,7 @@ async function spaceAction(space, action, memberId) {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="modal === 'blocks'">
|
||||
<p v-if="!blockedDevices.length" class="empty-blocks">차단한 기기가 없어요.</p>
|
||||
<p v-if="!blockedDevices.length" class="empty-blocks">{{ t('차단한 기기가 없어요.') }}</p>
|
||||
<ul v-else class="blocked-device-list">
|
||||
<li v-for="peer in blockedDevices" :key="peer.deviceId">
|
||||
<div>
|
||||
@@ -1438,33 +1472,39 @@ async function spaceAction(space, action, memberId) {
|
||||
><small>#{{ peer.code }}</small>
|
||||
</div>
|
||||
<button class="button small" :disabled="busy" @click="unblockDevice(peer)">
|
||||
차단 해제
|
||||
{{ t('차단 해제') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="text-button" @click="modal = 'device'">내 기기 설정으로 돌아가기</button>
|
||||
<button class="text-button" @click="modal = 'device'">
|
||||
{{ t('내 기기 설정으로 돌아가기') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else
|
||||
><form class="name-form device-settings-form" @submit.prevent="saveName">
|
||||
<label>
|
||||
<span class="name-field-heading"
|
||||
><span>기기 이름</span
|
||||
><span>{{ t('기기 이름') }}</span
|
||||
><small id="device-name-count" class="name-count"
|
||||
>{{ nameDraft.length }}/{{ DEVICE_NAME_MAX_LENGTH }}</small
|
||||
></span
|
||||
>
|
||||
<input
|
||||
v-model="nameDraft"
|
||||
aria-label="기기 이름"
|
||||
:aria-label="t('기기 이름')"
|
||||
aria-describedby="device-name-count"
|
||||
:maxlength="DEVICE_NAME_MAX_LENGTH"
|
||||
required
|
||||
/> </label
|
||||
><button class="button primary" :disabled="busy" aria-label="저장">저장</button>
|
||||
><button class="button primary" :disabled="busy" :aria-label="t('저장')">
|
||||
{{ t('저장') }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="device-blocks-setting">
|
||||
<span>차단한 기기</span
|
||||
><button class="button small" :disabled="busy" @click="openBlockedDevices">관리</button>
|
||||
<span>{{ t('차단한 기기') }}</span
|
||||
><button class="button small" :disabled="busy" @click="openBlockedDevices">
|
||||
{{ t('관리') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -159,7 +159,7 @@ async function save() {
|
||||
<div v-for="limit in limits" :key="limit.key">
|
||||
<dt>{{ limit.label }}</dt>
|
||||
<dd>
|
||||
<strong><SizeLabel :bytes="bytes(settings, limit.key)" /></strong
|
||||
<strong><SizeLabel language="ko" :bytes="bytes(settings, limit.key)" /></strong
|
||||
><small>{{ limit.hint }}</small>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t } from './i18n';
|
||||
|
||||
import { ref, shallowRef, computed, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { Download, X } from '@lucide/vue';
|
||||
const props = defineProps({ menuItem: Boolean });
|
||||
@@ -65,20 +67,25 @@ onUnmounted(() => {
|
||||
ref="button"
|
||||
class="pwa-install-button"
|
||||
:role="menuItem ? 'menuitem' : undefined"
|
||||
aria-label="hop. 앱 설치"
|
||||
:aria-label="t('hop. 앱 설치')"
|
||||
@click="install"
|
||||
>
|
||||
<Download :size="16" /><span>{{ menuItem ? '앱 설치' : '설치' }}</span>
|
||||
<Download :size="16" /><span>{{ menuItem ? t('앱 설치') : t('설치') }}</span>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="help" class="modal-backdrop" @click.self="close" @keydown.esc.stop="close">
|
||||
<section class="modal install-help" role="dialog" aria-modal="true" aria-label="앱 설치 안내">
|
||||
<section
|
||||
class="modal install-help"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="t('앱 설치 안내')"
|
||||
>
|
||||
<header>
|
||||
<h2>홈 화면에 hop.</h2>
|
||||
<h2>{{ t('홈 화면에 hop.') }}</h2>
|
||||
<button
|
||||
ref="closeButton"
|
||||
class="icon-button"
|
||||
aria-label="설치 안내 닫기"
|
||||
:aria-label="t('설치 안내 닫기')"
|
||||
@click="close"
|
||||
@keydown.tab.prevent
|
||||
>
|
||||
@@ -86,13 +93,15 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="ios">
|
||||
Safari에서 공유 버튼을 누른 뒤<br /><strong>홈 화면에 추가</strong>를 선택하세요.
|
||||
{{ t('Safari에서 공유 버튼을 누른 뒤') }}<br /><strong>{{ t('홈 화면에 추가') }}</strong
|
||||
>{{ t('를 선택하세요.') }}
|
||||
</p>
|
||||
<p v-else>
|
||||
브라우저 메뉴에서<br /><strong>앱 설치</strong> 또는 <strong>홈 화면에 추가</strong>를
|
||||
선택하세요.
|
||||
{{ t('브라우저 메뉴에서') }}<br /><strong>{{ t('앱 설치') }}</strong> {{ t('또는') }}
|
||||
<strong>{{ t('홈 화면에 추가') }}</strong
|
||||
>{{ t('를 선택하세요.') }}
|
||||
</p>
|
||||
<small>설치 후 홈 화면에서 바로 열 수 있어요.</small>
|
||||
<small>{{ t('설치 후 홈 화면에서 바로 열 수 있어요.') }}</small>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
44
apps/web/src/LanguagePicker.vue
Normal file
44
apps/web/src/LanguagePicker.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { Globe } from '@lucide/vue';
|
||||
import { language, languageOptions, setLanguage, t } from './i18n';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="language-picker">
|
||||
<Globe :size="16" aria-hidden="true" />
|
||||
<select :value="language" :aria-label="t('언어')" @change="setLanguage($event.target.value)">
|
||||
<option
|
||||
v-for="option in languageOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:lang="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.language-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.language-picker select {
|
||||
width: auto;
|
||||
min-height: 40px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.language-picker option {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -387,7 +387,7 @@ function modalKey(event) {
|
||||
</div>
|
||||
<div>
|
||||
<dt>요청한 파일 용량</dt>
|
||||
<dd><SizeLabel :bytes="state.autoUploadBytes" /></dd>
|
||||
<dd><SizeLabel language="ko" :bytes="state.autoUploadBytes" /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>자동 차단 시간</dt>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { t, intlLanguage, systemMessage } from './i18n';
|
||||
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import {
|
||||
@@ -117,11 +119,11 @@ async function loadClipboard(force = false, explicit = false) {
|
||||
urlInput.value = result;
|
||||
clipboardManaged.value = true;
|
||||
} else if (explicit) {
|
||||
feedback.value = '클립보드가 비어 있어요. 직접 입력하거나 붙여넣어 주세요.';
|
||||
feedback.value = t('클립보드가 비어 있어요. 직접 입력하거나 붙여넣어 주세요.');
|
||||
}
|
||||
} catch {
|
||||
if (current === generation && source.value === 'url')
|
||||
feedback.value = '클립보드를 읽지 못했어요. 직접 입력하거나 붙여넣어 주세요.';
|
||||
feedback.value = t('클립보드를 읽지 못했어요. 직접 입력하거나 붙여넣어 주세요.');
|
||||
} finally {
|
||||
if (current === generation) reading.value = false;
|
||||
}
|
||||
@@ -347,7 +349,7 @@ function fileProgress(index) {
|
||||
type="file"
|
||||
multiple
|
||||
class="sr-only"
|
||||
aria-label="보낼 파일 선택"
|
||||
:aria-label="t('보낼 파일 선택')"
|
||||
:disabled="!mutable"
|
||||
@change="choose($event.target.files)"
|
||||
/>
|
||||
@@ -361,23 +363,25 @@ function fileProgress(index) {
|
||||
<div class="content-heading">
|
||||
<strong
|
||||
><Link v-if="editorKind === 'url'" :size="18" /><ClipboardPaste v-else :size="18" />{{
|
||||
editorKind === 'url' ? '보낼 링크' : '보낼 텍스트'
|
||||
editorKind === 'url' ? t('보낼 링크') : t('보낼 텍스트')
|
||||
}}</strong
|
||||
><button
|
||||
class="text-button clipboard-load"
|
||||
:disabled="!mutable || reading"
|
||||
@click="loadClipboard(true, true)"
|
||||
>
|
||||
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '다시 가져오기' }}
|
||||
<ClipboardPaste :size="15" />{{ reading ? t('불러오는 중') : t('다시 가져오기') }}
|
||||
</button>
|
||||
</div>
|
||||
<component
|
||||
:is="editorKind === 'url' ? 'input' : 'textarea'"
|
||||
ref="editor"
|
||||
:value="urlInput"
|
||||
:aria-label="editorKind === 'url' ? '보낼 URL' : '보낼 텍스트'"
|
||||
:aria-label="editorKind === 'url' ? t('보낼 URL') : t('보낼 텍스트')"
|
||||
:placeholder="
|
||||
classified.kind === 'url' ? 'https://example.com' : '내용을 입력하거나 붙여넣어 주세요.'
|
||||
classified.kind === 'url'
|
||||
? 'https://example.com'
|
||||
: t('내용을 입력하거나 붙여넣어 주세요.')
|
||||
"
|
||||
:rows="editorKind === 'text' ? 6 : undefined"
|
||||
:disabled="!mutable"
|
||||
@@ -393,35 +397,41 @@ function fileProgress(index) {
|
||||
@keydown.enter="newline"
|
||||
:class="editorKind === 'url' ? 'current-url' : 'current-text'"
|
||||
/>
|
||||
<small v-if="classified.kind === 'text'" class="clipboard-count"
|
||||
>{{ characters.toLocaleString() }}자</small
|
||||
>
|
||||
<p v-if="feedback" class="content-feedback" role="status">{{ feedback }}</p>
|
||||
<small v-if="classified.kind === 'text'" class="clipboard-count">{{
|
||||
t('{p0}자', { p0: characters.toLocaleString(intlLanguage()) })
|
||||
}}</small>
|
||||
<p v-if="feedback" class="content-feedback" role="status">{{ systemMessage(feedback) }}</p>
|
||||
<p
|
||||
v-if="classified.kind === 'url' && classified.url.length > 8192"
|
||||
class="content-feedback"
|
||||
role="status"
|
||||
>
|
||||
링크가 너무 길어요. 최대 8,192자까지 보낼 수 있어요.
|
||||
{{ t('링크가 너무 길어요. 최대 8,192자까지 보낼 수 있어요.') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="classified.kind === 'text' && contentBytes > textLimitBytes"
|
||||
class="content-feedback"
|
||||
role="status"
|
||||
>
|
||||
텍스트는 최대 <SizeLabel :bytes="textLimitBytes" />까지 보낼 수 있어요.
|
||||
{{ t('텍스트는 최대') }} <SizeLabel :bytes="textLimitBytes" />{{
|
||||
t('까지 보낼 수 있어요.')
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="source === 'file'">
|
||||
<div v-if="!files.length" class="current-file-empty">
|
||||
<FileUp :size="32" /><strong>보낼 파일을 선택하세요.</strong
|
||||
><span>파일을 여기에 놓거나 클릭해 선택</span
|
||||
><button class="button" :disabled="!mutable" @click="pickFiles">파일 선택</button>
|
||||
<FileUp :size="32" /><strong>{{ t('보낼 파일을 선택하세요.') }}</strong
|
||||
><span>{{ t('파일을 여기에 놓거나 클릭해 선택') }}</span
|
||||
><button class="button" :disabled="!mutable" @click="pickFiles">
|
||||
{{ t('파일 선택') }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-else
|
||||
><div class="content-heading">
|
||||
<strong><FileIcon :size="18" />보낼 파일 {{ files.length }}개</strong
|
||||
><button class="text-button" :disabled="!mutable" @click="pickFiles">다시 선택</button>
|
||||
<strong><FileIcon :size="18" />{{ t('보낼 파일 {p0}개', { p0: files.length }) }}</strong
|
||||
><button class="text-button" :disabled="!mutable" @click="pickFiles">
|
||||
{{ t('다시 선택') }}
|
||||
</button>
|
||||
</div>
|
||||
<ul class="selected-files">
|
||||
<li v-for="(file, index) in files" :key="index">
|
||||
@@ -431,7 +441,7 @@ function fileProgress(index) {
|
||||
><button
|
||||
class="icon-button"
|
||||
:disabled="!mutable"
|
||||
:aria-label="`${file.name} 선택 해제`"
|
||||
:aria-label="t('{p0} 선택 해제', { p0: file.name })"
|
||||
@click="files.splice(index, 1)"
|
||||
>
|
||||
<X :size="15" />
|
||||
@@ -441,7 +451,7 @@ function fileProgress(index) {
|
||||
<progress
|
||||
:value="fileProgress(index).progress"
|
||||
max="100"
|
||||
:aria-label="`${file.name} 전송 진행률`"
|
||||
:aria-label="t('{p0} 전송 진행률', { p0: file.name })"
|
||||
/>
|
||||
<small>{{ fileProgress(index).progress }}%</small>
|
||||
<span v-if="fileProgress(index).status === 'failed'" role="status">{{
|
||||
@@ -454,7 +464,7 @@ function fileProgress(index) {
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="content-heading">
|
||||
<strong><Inbox :size="18" />보관함에서 가져오기</strong>
|
||||
<strong><Inbox :size="18" />{{ t('보관함에서 가져오기') }}</strong>
|
||||
</div>
|
||||
<div v-if="selectedVault" class="current-vault">
|
||||
<FileIcon v-if="selectedVault.file" :size="28" /><ClipboardPaste
|
||||
@@ -475,7 +485,7 @@ function fileProgress(index) {
|
||||
:disabled="!mutable"
|
||||
@click="selectedVault = null"
|
||||
>
|
||||
다른 자료 선택
|
||||
{{ t('다른 자료 선택') }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-else
|
||||
@@ -494,18 +504,18 @@ function fileProgress(index) {
|
||||
/><Link v-else :size="18" /><span
|
||||
><strong>{{ item.note || item.file?.name || item.url || item.text }}</strong
|
||||
><small
|
||||
>{{ item.role === 'self' ? '임시 저장' : '받은 항목' }} ·
|
||||
{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}</small
|
||||
>{{ item.role === 'self' ? t('임시 저장') : t('받은 항목') }} ·
|
||||
{{ item.file ? t('파일') : item.kind === 'text' ? t('텍스트') : t('링크') }}</small
|
||||
></span
|
||||
><Check v-if="selectedVault?.accessId === item.accessId" :size="16" />
|
||||
</button>
|
||||
<p v-if="!availableVaultItems.length" class="content-feedback">
|
||||
{{
|
||||
busy
|
||||
? '보관함을 불러오고 있어요.'
|
||||
? t('보관함을 불러오고 있어요.')
|
||||
: !filesAllowed
|
||||
? '보낼 링크나 텍스트가 없어요.'
|
||||
: '보관함이 비어 있어요.'
|
||||
? t('보낼 링크나 텍스트가 없어요.')
|
||||
: t('보관함이 비어 있어요.')
|
||||
}}
|
||||
</p>
|
||||
<button
|
||||
@@ -514,7 +524,7 @@ function fileProgress(index) {
|
||||
:disabled="busy"
|
||||
@click="emit('vault-more')"
|
||||
>
|
||||
더 보기
|
||||
{{ t('더 보기') }}
|
||||
</button></template
|
||||
>
|
||||
</template>
|
||||
@@ -525,10 +535,10 @@ function fileProgress(index) {
|
||||
>
|
||||
{{
|
||||
retryable
|
||||
? '같은 파일로 이어 보낼 수 있어요.'
|
||||
? t('같은 파일로 이어 보낼 수 있어요.')
|
||||
: transfer.status === 'pending'
|
||||
? '상대 기기의 수락을 기다립니다.'
|
||||
: '전송 중입니다.'
|
||||
? t('상대 기기의 수락을 기다립니다.')
|
||||
: t('전송 중입니다.')
|
||||
}}
|
||||
</p>
|
||||
<template v-if="!files.length"
|
||||
@@ -549,20 +559,20 @@ function fileProgress(index) {
|
||||
>
|
||||
{{
|
||||
preparing
|
||||
? '연결 확인 중'
|
||||
? t('연결 확인 중')
|
||||
: !transfer && retrySeconds > 0
|
||||
? `${retrySeconds}초 후 전송 가능`
|
||||
? t('{p0}초 후 전송 가능', { p0: retrySeconds })
|
||||
: retryable
|
||||
? '다시 전송'
|
||||
? t('다시 전송')
|
||||
: transfer
|
||||
? '전송 취소'
|
||||
? t('전송 취소')
|
||||
: saveMode
|
||||
? '저장하기'
|
||||
: '전송하기'
|
||||
? t('저장하기')
|
||||
: t('전송하기')
|
||||
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="retryable" class="content-switches">
|
||||
<button class="text-button room-leave" @click="emit('cancel')">전송 취소</button>
|
||||
<button class="text-button room-leave" @click="emit('cancel')">{{ t('전송 취소') }}</button>
|
||||
</div>
|
||||
<div v-else-if="!contentMode && !transfer" class="content-switches">
|
||||
<button
|
||||
@@ -571,27 +581,31 @@ function fileProgress(index) {
|
||||
:disabled="!mutable"
|
||||
@click="pickFiles"
|
||||
>
|
||||
<FileIcon :size="14" />대신 파일 선택하기</button
|
||||
<FileIcon :size="14" />{{ t('대신 파일 선택하기') }}</button
|
||||
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">
|
||||
<Link :size="14" />대신 클립보드에서 가져오기</button
|
||||
<Link :size="14" />{{ t('대신 클립보드에서 가져오기') }}</button
|
||||
><button
|
||||
v-if="vaultEnabled && source !== 'vault'"
|
||||
class="text-button"
|
||||
:disabled="!mutable"
|
||||
@click="toVault"
|
||||
>
|
||||
<Inbox :size="14" />보관함에서 가져오기
|
||||
<Inbox :size="14" />{{ t('보관함에서 가져오기') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!filesAllowed" class="content-feedback" role="status">
|
||||
{{
|
||||
transfer?.kind === 'file' && transfer.status === 'accepted'
|
||||
? '새 파일 전송은 제한되어 있어요. 이미 수락한 전송은 계속할 수 있어요.'
|
||||
: '파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.'
|
||||
? t('새 파일 전송은 제한되어 있어요. 이미 수락한 전송은 계속할 수 있어요.')
|
||||
: t('파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.')
|
||||
}}
|
||||
</p>
|
||||
<p class="consent-note">
|
||||
{{ saveMode ? '저장한 자료는 최대 7일 후 삭제됩니다.' : '상대가 수락한 후에만 전달됩니다.' }}
|
||||
{{
|
||||
saveMode
|
||||
? t('저장한 자료는 최대 7일 후 삭제됩니다.')
|
||||
: t('상대가 수락한 후에만 전달됩니다.')
|
||||
}}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { formatSize } from './displayFormat';
|
||||
const props = defineProps({ bytes: { type: Number, required: true } });
|
||||
const formatted = computed(() => formatSize(props.bytes));
|
||||
import { language } from './i18n';
|
||||
import { translate } from '@hop/shared';
|
||||
const props = defineProps({ bytes: { type: Number, required: true }, language: String });
|
||||
const formatted = computed(() =>
|
||||
translate(formatSize(props.bytes), props.language || language.value),
|
||||
);
|
||||
const parts = computed(() => formatted.value.match(/^(.*?)(\.\d+)?(KB|MB|GB)$/));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t, language, applyInitialLanguage, systemMessage } from './i18n';
|
||||
import { readDeviceInfo } from './deviceInfo';
|
||||
import { PASSWORD_MIN_LENGTH } from '@hop/shared';
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
@@ -50,9 +51,9 @@ function applyServicePolicy(policy) {
|
||||
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('서버 연결을 확인해 주세요.');
|
||||
if (!socket?.connected) throw Error(t('서버 연결을 확인해 주세요.'));
|
||||
const result = await socket.timeout(5000).volatile.emitWithAck('location:update', position);
|
||||
if (!result?.ok) throw Error(result?.message ?? '위치를 확인하지 못했습니다.');
|
||||
if (!result?.ok) throw Error(result?.message ?? t('위치를 확인하지 못했습니다.'));
|
||||
}
|
||||
export function clearLocation() {
|
||||
if (socket?.connected) socket.emit('location:clear');
|
||||
@@ -64,7 +65,7 @@ let socket;
|
||||
let reauthorizing;
|
||||
export async function waitForConnection() {
|
||||
if (socket?.connected) return;
|
||||
if (!socket) throw Error('서버 연결을 확인해 주세요.');
|
||||
if (!socket) throw Error(t('서버 연결을 확인해 주세요.'));
|
||||
await new Promise((resolve, reject) => {
|
||||
const stop = watch(connected, (ready) => {
|
||||
if (!ready) return;
|
||||
@@ -74,7 +75,7 @@ export async function waitForConnection() {
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
stop();
|
||||
reject(Error('서버에 다시 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'));
|
||||
reject(Error(t('서버에 다시 연결하지 못했어요. 잠시 후 다시 시도해 주세요.')));
|
||||
}, 5000);
|
||||
socket.connect();
|
||||
});
|
||||
@@ -88,7 +89,7 @@ export async function requestTransfer(path, input) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (true) {
|
||||
if (device.value?.id !== owner)
|
||||
throw Error('기기 세션이 변경되었습니다. 다시 선택해 주세요.');
|
||||
throw Error(t('기기 세션이 변경되었습니다. 다시 선택해 주세요.'));
|
||||
try {
|
||||
const result = await api(path, 'POST', input, key);
|
||||
latestRequestedTransfer.value = result.requestId;
|
||||
@@ -112,6 +113,7 @@ export async function api(path, method = 'GET', body, key, signal) {
|
||||
signal,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept-Language': path.startsWith('/admin/') ? 'ko' : language.value,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(method !== 'GET' ? { 'X-CSRF-Token': csrfToken.value } : {}),
|
||||
...(key ? { 'Idempotency-Key': key } : {}),
|
||||
@@ -135,17 +137,26 @@ export async function api(path, method = 'GET', body, key, signal) {
|
||||
targetDeviceId: payload.limitScope === 'pair' ? body?.targetDeviceId : null,
|
||||
};
|
||||
}
|
||||
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
|
||||
status: response.status,
|
||||
code: payload.code,
|
||||
retryAfterSeconds,
|
||||
});
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
path.startsWith('/admin/')
|
||||
? payload.message
|
||||
: systemMessage(payload.message ?? t('요청을 처리하지 못했습니다.')),
|
||||
),
|
||||
{
|
||||
status: response.status,
|
||||
admin: path.startsWith('/admin/'),
|
||||
code: payload.code,
|
||||
retryAfterSeconds,
|
||||
},
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
export async function bootstrap() {
|
||||
const init = async () => {
|
||||
const s = await api('/guest/session', 'POST', {});
|
||||
applyInitialLanguage(s.defaultLanguage);
|
||||
applyServicePolicy(s.servicePolicy);
|
||||
device.value = s.device;
|
||||
csrfToken.value = s.csrfToken;
|
||||
@@ -185,11 +196,13 @@ function connect() {
|
||||
window.dispatchEvent(new Event('hop-account-revoked'));
|
||||
});
|
||||
socket.on('service:policy', applyServicePolicy);
|
||||
socket.on('service:blocked', () => showError(Error('이 접속은 서비스 이용이 제한되었습니다.')));
|
||||
socket.on('service:blocked', () =>
|
||||
showError(Error(t('이 접속은 서비스 이용이 제한되었습니다.'))),
|
||||
);
|
||||
socket.on('connect_error', (err) => {
|
||||
connected.value = false;
|
||||
if (err.message === 'ACCESS_BLOCKED') {
|
||||
showError(Error('이 접속은 서비스 이용이 제한되었습니다.'));
|
||||
showError(Error(t('이 접속은 서비스 이용이 제한되었습니다.')));
|
||||
return;
|
||||
}
|
||||
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
|
||||
@@ -234,13 +247,21 @@ function connect() {
|
||||
let errorTimer;
|
||||
export function showError(err) {
|
||||
clearTimeout(errorTimer);
|
||||
const raw = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
const raw =
|
||||
err instanceof Error
|
||||
? err.admin
|
||||
? err.message
|
||||
: systemMessage(err.message)
|
||||
: t('처리 중 오류가 발생했습니다.');
|
||||
const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw)
|
||||
? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'
|
||||
? t('서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.')
|
||||
: raw;
|
||||
const displayed =
|
||||
err?.retryAfterSeconds > 0
|
||||
? `${message} ${Math.ceil(err.retryAfterSeconds)}초 후 다시 시도할 수 있어요.`
|
||||
? t('{p0} {p1}초 후 다시 시도할 수 있어요.', {
|
||||
p0: message,
|
||||
p1: Math.ceil(err.retryAfterSeconds),
|
||||
})
|
||||
: message;
|
||||
error.value = displayed;
|
||||
errorTimer = setTimeout(() => {
|
||||
@@ -325,7 +346,7 @@ async function deliver(id) {
|
||||
}
|
||||
export async function retryDelivery(id, values) {
|
||||
drafts.set(id, { ...values, key: crypto.randomUUID() });
|
||||
if (!(await deliver(id))) throw new Error(error.value || '전송을 완료하지 못했습니다.');
|
||||
if (!(await deliver(id))) throw new Error(error.value || t('전송을 완료하지 못했습니다.'));
|
||||
}
|
||||
export async function requestAction(id, action) {
|
||||
await api(
|
||||
@@ -414,8 +435,8 @@ export async function uploadFile(id, index, file, accountId) {
|
||||
uploadRequests.set(progressKey, controller);
|
||||
const check = () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (cancelledTransfers.has(id)) throw Error('전송이 취소되었습니다.');
|
||||
if (accountId && account.value?.id !== accountId) throw Error('계정 세션이 종료되었습니다.');
|
||||
if (cancelledTransfers.has(id)) throw Error(t('전송이 취소되었습니다.'));
|
||||
if (accountId && account.value?.id !== accountId) throw Error(t('계정 세션이 종료되었습니다.'));
|
||||
};
|
||||
const sendChunk = (uploadId, offset) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -446,7 +467,7 @@ export async function uploadFile(id, index, file, accountId) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(payload);
|
||||
else
|
||||
reject(
|
||||
Object.assign(Error(payload.message || '파일 조각을 보내지 못했습니다.'), {
|
||||
Object.assign(Error(payload.message || t('파일 조각을 보내지 못했습니다.')), {
|
||||
status: xhr.status,
|
||||
code: payload.code,
|
||||
}),
|
||||
@@ -454,7 +475,7 @@ export async function uploadFile(id, index, file, accountId) {
|
||||
};
|
||||
xhr.onerror = xhr.onabort = () => {
|
||||
finish();
|
||||
reject(Object.assign(Error('업로드 연결이 중단되었습니다.'), { status: 0 }));
|
||||
reject(Object.assign(Error(t('업로드 연결이 중단되었습니다.')), { status: 0 }));
|
||||
};
|
||||
xhr.send(file.slice(offset, offset + chunkBytes));
|
||||
});
|
||||
@@ -533,7 +554,7 @@ export async function uploadFile(id, index, file, accountId) {
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message: error.message || '업로드가 중단되었습니다.',
|
||||
message: error.message || t('업로드가 중단되었습니다.'),
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -576,7 +597,7 @@ export async function retryFiles(id, files) {
|
||||
r.files.length !== files.length ||
|
||||
files.some((f, i) => f.name !== r.files[i].name || f.size !== r.files[i].size)
|
||||
)
|
||||
throw Error('수락한 파일 목록과 이름·크기가 같은 파일을 선택해 주세요.');
|
||||
throw Error(t('수락한 파일 목록과 이름·크기가 같은 파일을 선택해 주세요.'));
|
||||
fileDrafts.set(id, files);
|
||||
await deliverFiles(id);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from './i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Reserve tabs during the acceptance click; later socket updates have no user activation.
|
||||
@@ -21,8 +22,8 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
}
|
||||
if (tab) {
|
||||
tab.opener = null;
|
||||
tab.document.title = 'hop. 링크를 받는 중';
|
||||
tab.document.body.textContent = '수락한 링크를 받고 있어요. 잠시만 기다려 주세요.';
|
||||
tab.document.title = t('hop. 링크를 받는 중');
|
||||
tab.document.body.textContent = t('수락한 링크를 받고 있어요. 잠시만 기다려 주세요.');
|
||||
}
|
||||
tabs.push(tab);
|
||||
}
|
||||
|
||||
99
apps/web/src/i18n.js
Normal file
99
apps/web/src/i18n.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
normalizeLanguage,
|
||||
preferredLanguage,
|
||||
translate,
|
||||
translateSystemMessage,
|
||||
} from '@hop/shared';
|
||||
|
||||
const storageKey = 'hop-language';
|
||||
function savedLanguage() {
|
||||
try {
|
||||
return normalizeLanguage(localStorage.getItem(storageKey));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let explicit = savedLanguage();
|
||||
const browserLanguage = () =>
|
||||
preferredLanguage((navigator.languages || [navigator.language]).join(','));
|
||||
export const language = ref(explicit || browserLanguage());
|
||||
export const languageOptions = [
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'en', label: 'English' },
|
||||
];
|
||||
export function setLanguage(value) {
|
||||
const next = normalizeLanguage(value);
|
||||
if (!next) return;
|
||||
explicit = next;
|
||||
language.value = next;
|
||||
try {
|
||||
localStorage.setItem(storageKey, next);
|
||||
} catch {
|
||||
/* Storage may be unavailable. */
|
||||
}
|
||||
}
|
||||
export function applyInitialLanguage(value) {
|
||||
if (!explicit) language.value = normalizeLanguage(value) || browserLanguage();
|
||||
}
|
||||
export function t(message, values = {}) {
|
||||
return translate(message, language.value, values);
|
||||
}
|
||||
export function systemMessage(message) {
|
||||
return translateSystemMessage(message, language.value);
|
||||
}
|
||||
export const intlLanguage = () => ({ ko: 'ko-KR', ja: 'ja-JP', en: 'en-US' })[language.value];
|
||||
export function userDate(value, options = {}) {
|
||||
return new Intl.DateTimeFormat(intlLanguage(), options).format(new Date(value));
|
||||
}
|
||||
export function userExpiry(value) {
|
||||
if (!value) return t('확인 불가');
|
||||
if (language.value === 'ko' || language.value === 'ja') {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat(intlLanguage(), {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
.formatToParts(new Date(value))
|
||||
.map(({ type, value }) => [type, value]),
|
||||
);
|
||||
return language.value === 'ko'
|
||||
? `${parts.year}년 ${parts.month}월 ${parts.day}일 ${parts.hour}:${parts.minute}`
|
||||
: `${parts.year}年${parts.month}月${parts.day}日 ${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
return userDate(value, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
}
|
||||
watch(
|
||||
language,
|
||||
(value) => {
|
||||
document.documentElement.lang = value;
|
||||
document.title = `hop. — ${t('기기 사이를 가볍게.')}`;
|
||||
document
|
||||
.querySelector('link[rel="manifest"]')
|
||||
?.setAttribute(
|
||||
'href',
|
||||
value === 'ko' ? '/manifest.webmanifest' : `/manifest.${value}.webmanifest`,
|
||||
);
|
||||
document
|
||||
.querySelector('meta[name="description"]')
|
||||
?.setAttribute('content', t('서로 다른 운영체제에서도 파일과 링크를 자유롭게 주고받으세요.'));
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== storageKey) return;
|
||||
explicit = normalizeLanguage(event.newValue);
|
||||
if (explicit) language.value = explicit;
|
||||
});
|
||||
@@ -4479,3 +4479,42 @@ a.button {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* Keep all three native language names accessible without crowding the mobile header. */
|
||||
.header-actions .language-picker {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.site-header .login-button {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.auth-links {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 10px;
|
||||
}
|
||||
.auth-links .text-button {
|
||||
white-space: nowrap;
|
||||
padding-inline: 0;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.site-header .header-tagline {
|
||||
display: none;
|
||||
}
|
||||
.site-header .header-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
.site-header .login-button {
|
||||
padding-inline: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
.header-actions .language-picker select {
|
||||
max-width: 84px;
|
||||
font-size: 16px;
|
||||
padding-inline: 3px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.header-actions .language-picker > svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user