Add Korean Japanese and English user interfaces

This commit is contained in:
2026-10-15 18:21:17 +09:00
parent 8fb8c203f0
commit b831003e7d
41 changed files with 3074 additions and 351 deletions

View File

@@ -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>

View File

@@ -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>{{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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
View 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;
});

View File

@@ -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;
}
}