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

@@ -150,3 +150,7 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
관리자 용량은 기본적으로 값만 표시하며 **수정 → 설정 저장/취소**로 변경합니다. 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 설정하며 저장한 값은 DB에 보존됩니다. GB 입력은 소수점 셋째 자리까지 지원하고 입력창을 벗어나면 `20.000`처럼 정리합니다. **기본값으로 돌리기**는 NAS `.env`의 초기 설정값을 초안으로 불러오며 **설정 저장**을 눌러야 적용됩니다. 네트워크 토글과 규칙 추가·제거는 언제든 각각 즉시 적용하고, 자동 블랙리스트의 숫자 감지 기준만 해당 영역의 **수정 → 감지 기준 저장/취소**로 변경합니다. 서로 다른 영역의 변경이 입력 중인 감지 기준을 함께 저장하지 않습니다. IP 대역은 CIDR 외에 `203.0.113.*`처럼 뒤쪽 전체 자리의 IPv4 와일드카드도 입력할 수 있고, `203.0.113.0/24`로 정규화합니다. 저장 파일 목록은 업로더 ID와 계정 이메일(로그인하지 않은 사용자는 ‘게스트(Guest)’)을 표시합니다.
네트워크의 **파일 전송 금지**를 켜면 새 파일 전송/저장 메뉴를 숨기고 링크·텍스트만 허용합니다. 이미 수락한 파일 업로드와 기존 다운로드는 유지하며, 변경 사항은 접속 중인 사용자에게도 반영합니다. 용량 표시는 천 단위 쉼표를 사용하고, 정수 부분은 선명하게, 소수점 부분은 더 작고 흐리게 표시합니다. 단위 앞에 시각적 간격을 두며 전송·수신·보관함·관리자 화면에 같은 표시를 적용합니다. 관리자 저장 파일은 `업로더UUID · 게스트(Guest)` 또는 `업로더UUID · 이메일`로 표시하고 `접속 지역 · 일본(JP) · Tokyo · IP`처럼 지역과 IP도 구분합니다.
## 한국어·일본어·영어
사용자 화면은 세 언어를 지원하며 상단의 **한국어 / 日本語 / English** 선택창에서 언제든 바꿀 수 있습니다. 직접 선택한 언어를 브라우저에 저장하고, 첫 방문은 확인된 국가(한국→한국어, 일본→일본어, 그 외→영어), 국가 미확인 시 브라우저 언어 순서로 결정합니다. 관리자 화면은 한국어를 유지합니다. 전송 원문과 기기 이름·파일명은 번역하지 않으며 언어를 바꿔도 작성 중인 내용과 기존 계정·파일은 유지합니다. 자세한 범위와 검증은 [언어 지원](docs/LANGUAGES.md)을 참고하세요.

View File

@@ -13,6 +13,9 @@ import { existsSync } from 'node:fs';
import { z } from 'zod';
import {
PASSWORD_MIN_LENGTH,
initialLanguage,
preferredLanguage,
translateSystemMessage,
devicePatch,
requestInput,
spaceInput,
@@ -246,6 +249,27 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
}
};
const id = (req: FastifyRequest) => z.object({ id: z.uuid() }).parse(req.params).id;
// Only system messages are localized. Transferred content and account data stay byte-for-byte intact.
const requestLanguage = (req: FastifyRequest) =>
preferredLanguage(req.headers['accept-language'], 'ko');
app.addHook('onSend', async (req, reply, payload) => {
if (
!req.url.startsWith('/api/') ||
req.url.startsWith('/api/admin/') ||
!String(reply.getHeader('content-type')).includes('application/json') ||
typeof payload !== 'string'
)
return payload;
const language = requestLanguage(req);
if (language === 'ko') return payload;
const body = JSON.parse(payload);
if (body && typeof body.message === 'string') {
body.message = translateSystemMessage(body.message, language);
reply.header('Content-Language', language);
return JSON.stringify(body);
}
return payload;
});
app.addHook('onRequest', async (req, reply) => {
reply
.header('X-Content-Type-Options', 'nosniff')
@@ -479,6 +503,10 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
device: service.device(guest.id),
csrfToken: service.csrf(token!),
expiresAt: guest.expiresAt,
defaultLanguage: initialLanguage(
visitorRegion(req, cfg).countryCode,
req.headers['accept-language'],
),
servicePolicy: service.admin.publicPolicy(),
};
});
@@ -884,6 +912,8 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
input.password,
input.displayName,
ip(req) ?? 'unknown',
undefined,
requestLanguage(req),
);
});
app.post('/api/auth/setup', async (req) => {
@@ -895,6 +925,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
input.displayName,
ip(req) ?? 'unknown',
input.setupToken,
requestLanguage(req),
);
});
app.post('/api/auth/verify-email', async (req, reply) => {
@@ -914,6 +945,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
.parse(req.body).email,
'verify',
ip(req) ?? 'unknown',
requestLanguage(req),
),
);
app.post('/api/auth/login', { bodyLimit: 16384 }, async (req, reply) => {
@@ -949,6 +981,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
.parse(req.body).email,
'reset',
ip(req) ?? 'unknown',
requestLanguage(req),
),
);
app.post('/api/auth/reset-password', async (req) => {

View File

@@ -2,7 +2,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
import { and, eq, gt, isNull, inArray } from 'drizzle-orm';
import argon2 from 'argon2';
import type { Service } from './service.js';
import { PASSWORD_MIN_LENGTH, type Account } from '@hop/shared';
import { PASSWORD_MIN_LENGTH, translate, type Account } from '@hop/shared';
import * as s from './db/schema.js';
import { fail, hash, randomToken } from './security.js';
export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string };
@@ -88,7 +88,12 @@ export class Auth {
fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`);
return argon2.hash(value, passwordOptions);
}
async token(user: User, purpose: 'verify' | 'reset' | 'email-change', value?: string) {
async token(
user: User,
purpose: 'verify' | 'reset' | 'email-change',
value?: string,
language = 'ko',
) {
if (!this.mailReady)
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
const token = randomToken(),
@@ -127,8 +132,23 @@ export class Auth {
await this.sendMail({
idempotencyKey: `auth-${purpose}-${hash(token)}`,
to: value ?? user.email!,
subject: purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
text: `${purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면'} 다음 주소를 열어 주세요.\n${this.service.cfg.appUrl}/#${action}=${token}\n이 링크는 한 번만 사용할 수 있습니다.`,
subject: translate(
purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
language,
),
text: translate(
'{p0} 다음 주소를 열어 주세요.\n{p1}/#{p2}={p3}\n이 링크는 한 번만 사용할 수 있습니다.',
language,
{
p0: translate(
purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면',
language,
),
p1: this.service.cfg.appUrl,
p2: action,
p3: token,
},
),
});
}
async register(
@@ -137,6 +157,7 @@ export class Auth {
displayName: string,
ip: string,
setupToken?: string,
language = 'ko',
) {
const { db, cfg, limiter } = this.service;
limiter.hit(`register-ip:${ip}`, 5, 3600000);
@@ -174,7 +195,7 @@ export class Auth {
.run();
return this.user(id);
});
await this.token(user, 'verify');
await this.token(user, 'verify', undefined, language);
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
}
consume(
@@ -324,7 +345,7 @@ export class Auth {
}
}
}
async resend(email: string, purpose: 'verify' | 'reset', ip: string) {
async resend(email: string, purpose: 'verify' | 'reset', ip: string, language = 'ko') {
const normalized = normalizeEmail(email);
this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000);
this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000);
@@ -334,7 +355,7 @@ export class Auth {
((purpose === 'verify' && user.status === 'pending') ||
(purpose === 'reset' && ['active', 'pending'].includes(user.status)))
)
await this.token(user, purpose);
await this.token(user, purpose, undefined, language);
return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' };
}
async resetPassword(token: string, password: string) {

View File

@@ -0,0 +1,148 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
initialLanguage,
normalizeLanguage,
preferredLanguage,
translate,
translateSystemMessage,
} from '../../../packages/shared/src/localization.js';
import { messages } from '../../../packages/shared/src/messages.js';
import { fixture } from './helpers.js';
test('language priority uses a known country, supported browser languages, quality and safe defaults', () => {
assert.equal(initialLanguage('KR', 'ja-JP,en;q=0.9'), 'ko');
assert.equal(initialLanguage('JP', 'ko-KR'), 'ja');
assert.equal(initialLanguage('CN', 'ko'), 'en');
assert.equal(initialLanguage('US', 'ja'), 'en');
assert.equal(initialLanguage(null, 'fr-FR,ja-JP;q=0.8,en;q=0.5'), 'ja');
assert.equal(initialLanguage('XX', 'ko;q=0,en;q=0.2'), 'en');
assert.equal(initialLanguage(null, undefined), 'en');
assert.equal(preferredLanguage('en;q=oops,ja;q=0.8,ko;q=0.9'), 'ko');
assert.equal(preferredLanguage('ja;q=0,en;q=0', 'ko'), 'ko');
assert.equal(normalizeLanguage('EN-us'), 'en');
assert.equal(normalizeLanguage('fr'), null);
});
test('English/Japanese catalog is complete, contains matching placeholders, and interpolates once without treating content as HTML', () => {
for (const [source, translations] of Object.entries(messages)) {
const placeholders = (text) => [...new Set(text.match(/\{p\d+\}/g) || [])].sort();
for (const language of ['en', 'ja']) {
assert.ok(translations[language]?.trim(), `${language}: ${source}`);
assert.equal(/[가-힣]/.test(translations[language]), false, `${language}: ${source}`);
assert.deepEqual(
placeholders(translations[language]),
placeholders(source),
`${language}: ${source}`,
);
}
}
const name = '<script>日本語 한글 {p1}</script>';
assert.equal(translate('{p0}에 보내기', 'en', { p0: name }), 'Send to ' + name);
assert.equal(
translateSystemMessage('비밀번호는 8~1024자로 입력해 주세요.', 'ja'),
'パスワードは8~1,024文字で入力してください。',
);
assert.equal(
translateSystemMessage('https://example.test/한글', 'en'),
'https://example.test/한글',
);
assert.equal(translateSystemMessage('Email verified.', 'ja'), 'メールアドレスを確認しました。');
assert.equal(
translateSystemMessage('Mac declined the transfer.', 'ja'),
'Macが受信を辞退しました。',
);
});
test('guest default language trusts verified country headers only, and user API system messages use the chosen language', async () => {
const f = await fixture(false, undefined, { clientIpSource: 'cloudflare' });
try {
for (const [country, browser, expected] of [
['KR', 'en', 'ko'],
['JP', 'en', 'ja'],
['CN', 'ja', 'en'],
['XX', 'ja-JP', 'ja'],
]) {
const response = await f.call(null, '/guest/session', 'POST', {}, undefined, {
'cf-connecting-ip': '8.8.8.8',
'cf-ipcountry': country,
'accept-language': browser,
});
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().defaultLanguage, expected);
assert.equal(response.headers['cache-control'], 'no-store');
}
f.cfg.trustedProxies = [];
const spoofed = await f.call(null, '/guest/session', 'POST', {}, undefined, {
'cf-connecting-ip': '8.8.8.8',
'cf-ipcountry': 'JP',
'accept-language': 'ko-KR',
});
assert.equal(spoofed.json().defaultLanguage, 'ko');
const client = await f.create();
for (const language of ['en', 'ja']) {
const response = await f.call(
client,
'/auth/login',
'POST',
{ email: 'nobody@hop.test', password: '12345678' },
undefined,
{ 'accept-language': language },
);
assert.equal(response.statusCode, 401);
assert.equal(response.json().code, 'LOGIN_FAILED');
assert.equal(
response.json().message,
translate('이메일 또는 비밀번호를 확인해 주세요.', language),
);
const invalid = await f.call(client, '/device/me', 'PATCH', { name: '' }, undefined, {
'accept-language': language,
});
assert.equal(invalid.json().message, translate('입력 내용을 확인해 주세요.', language));
}
const korean = await f.call(client, '/device/me', 'PATCH', { name: '' });
assert.equal(korean.json().message, '입력 내용을 확인해 주세요.');
} finally {
await f.close();
}
});
test('verification and reset emails use the chosen language without changing one-use URLs or user data', async () => {
const sent = [];
const f = await fixture(false, async (mail) => sent.push(mail), { registrationMode: 'open' });
try {
const client = await f.create();
for (const language of ['ja', 'en']) {
const email = language + '@hop.test';
const response = await f.call(
client,
'/auth/register',
'POST',
{
email,
password: '12345678',
passwordConfirmation: '12345678',
displayName: '日本語 한글 😀',
},
undefined,
{ 'accept-language': language },
);
assert.equal(response.statusCode, 200, response.body);
assert.equal(
response.json().message,
translate('가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.', language),
);
assert.equal(sent.at(-1).subject, translate('hop. 이메일 인증', language));
assert.equal(/[가-힣]/.test(sent.at(-1).text), false);
assert.match(sent.at(-1).text, /http:\/\/hop.test\/#verify=[\w-]+/);
const reset = await f.call(client, '/auth/forgot-password', 'POST', { email }, undefined, {
'accept-language': language,
});
assert.equal(reset.statusCode, 200, reset.body);
assert.equal(sent.at(-1).subject, translate('hop. 비밀번호 재설정', language));
assert.match(sent.at(-1).text, /#reset-password=[\w-]+/);
}
} finally {
await f.close();
}
});

View File

@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="서로 다른 운영체제에서도 파일과 링크를 자유롭게 주고받으세요."
/>
<title>hop. — 기기 사이를 가볍게.</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />

View File

@@ -0,0 +1,32 @@
{
"id": "/",
"name": "hop. — A lighter way between devices.",
"short_name": "hop.",
"description": "Send files, links and text between devices, without signing up.",
"lang": "en",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -0,0 +1,32 @@
{
"id": "/",
"name": "hop. — デバイスの間を、軽やかに。",
"short_name": "hop.",
"description": "登録せずに、デバイス間でファイル・リンク・テキストを送信。",
"lang": "ja",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -7,7 +7,7 @@ self.addEventListener('fetch', (event) => {
fetch(event.request).catch(
() =>
new Response(
'<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>hop.</title><body style="background:#000;color:#f5f5f5;font:16px system-ui;text-align:center;padding:25vh 24px"><h1>hop.</h1><p>인터넷 연결이 필요해요.</p><p>연결한 뒤 다시 열어주세요.</p><a href="/" style="color:inherit">다시 시도</a></body></html>',
'<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>hop.</title><body style="background:#000;color:#f5f5f5;font:16px system-ui;text-align:center;padding:25vh 24px"><h1>hop.</h1><p>인터넷 연결이 필요해요.</p><p>연결한 뒤 다시 열어주세요.</p><a href="/" style="color:inherit">다시 시도</a><script>try{var l;try{l=localStorage.getItem("hop-language")}catch(e){};var m={ko:["인터넷 연결이 필요해요.","연결한 뒤 다시 열어주세요.","다시 시도"],ja:["インターネット接続が必要です。","接続してから、もう一度hop.を開いてください。","もう一度試す"],en:["An internet connection is required.","Reconnect, then open hop. again.","Try again"]};var b=(navigator.languages||[navigator.language]).map(function(x){return String(x).toLowerCase().split("-")[0]}).find(function(x){return ["ko","ja","en"].includes(x)})||"en";l=["ko","ja","en"].includes(l)?l:b;document.documentElement.lang=l;document.querySelectorAll("p").forEach(function(p,i){p.textContent=m[l][i]});document.querySelector("a").textContent=m[l][2]}catch(e){}</script></body></html>',
{
status: 503,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },

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

View File

@@ -553,3 +553,7 @@ sudo docker compose -f docker-compose.yaml up -d --build
## 거절 누적·게스트 표시·용량 소수점 가독성
한 번 거절하면 바로 5분 기다리는 정책을 연속 3회 거절 시에만 적용하도록 변경했습니다. 텍스트·링크·파일과 보관함 재전송에 같은 정책을 적용합니다. 기존 횟수 제한과 수신자 차단은 유지합니다. 용량 숫자는 천 단위 구분을 유지하고 소수점 부분을 더 작고 흐리게, 단위 앞에는 시각적 간격을 둡니다. 저장 파일의 주체는 `UUID · 게스트(Guest)` 또는 `UUID · 이메일`, 지역은 `접속 지역 · 일본(JP) · Tokyo · IP` 형식으로 표시합니다. DB 구조·저장된 바이트 수·Compose 변경 없이 최신 소스를 재빌드·배포하면 됩니다.
## 한국어·일본어·영어 지원
사용자 화면과 사용자가 요청한 인증·재설정 메일은 선택한 언어를 사용합니다. 관리자 페이지는 한국어를 유지합니다. 처음 접속하면 신뢰된 Cloudflare 국가 값에 따라 한국/일본/그 외를 한국어/일본어/영어로 표시하며, 국가가 없으면 브라우저 언어를 참고합니다. 추가 GPS 권한이나 외부 위치 조회는 없습니다. 상단 언어 선택창의 직접 선택은 브라우저에 저장하고 국가 판별보다 우선합니다. [언어 지원](docs/LANGUAGES.md)에 우선순위와 세부 동작을 정리했습니다. 기존 Compose·환경변수·DB·자료는 유지하며 평소 Git pull 후 재빌드·배포로 적용합니다.

36
docs/LANGUAGES.md Normal file
View File

@@ -0,0 +1,36 @@
# 사용자 화면의 언어
hop.의 사용자 화면은 한국어(`ko`), 일본어(`ja`), 영어(`en`)를 지원합니다. 관리자 페이지의 본문·관리 기록·날짜 표시는 한국어를 유지합니다.
## 처음 접속할 때
1. 같은 브라우저에 저장된 직접 선택 언어가 있으면 가장 먼저 적용합니다.
2. 저장된 선택이 없고 기존 신뢰 프록시 설정으로 국가를 확인할 수 있으면 대한민국은 한국어, 일본은 일본어, 그 외 국가는 영어로 표시합니다.
3. 국가를 확인할 수 없으면 브라우저의 지원 언어 목록을 참고하고, 지원 언어가 없으면 영어를 사용합니다.
새 위치 권한 요청, GPS 조회, 외부 IP 조회 서비스나 GeoIP DB를 추가하지 않습니다. 기존 `CLIENT_IP_SOURCE=cloudflare`와 `TRUSTED_PROXY_CIDRS`로 확인한 국가만 사용합니다. 직접 보낸 위조 국가 헤더는 기본 언어 판별에 쓰지 않습니다. IP 기반 국가가 실제 거주지와 같다는 보장은 없으므로 항상 직접 변경할 수 있습니다.
상단 지구본 옆 선택창에서 **한국어 / 日本語 / English**를 선택합니다. 선택은 `localStorage`의 `hop-language`에 저장하며, 같은 브라우저의 다른 탭과도 동기화합니다. 로그인 계정의 공통 설정이 아니므로 브라우저마다 다른 언어를 쓸 수 있습니다. 사이트 데이터를 지우면 저장된 선택도 지워집니다. 저장소를 쓸 수 없어도 현재 페이지에서 선택한 언어는 적용됩니다.
국가 응답을 기다리는 중 직접 선택한 언어도 우선합니다. 언어 전환은 앱을 다시 시작하거나 기기·세션·계정을 초기화하지 않습니다. 작성 중인 내용과 선택한 파일·전송 상태는 유지합니다.
## 번역 범위
기기 목록·연결 공간·파일/클립보드 전송·수락/거절·텍스트 복사·로그인/가입/비밀번호 변경·계정 보관함·알림·설치 안내를 번역합니다. 날짜는 사용자 화면의 언어와 브라우저 시간대에 맞춰 표시하고, 관리자 날짜는 기존 한국 시간 표시를 유지합니다. 사용자가 요청한 인증·비밀번호 재설정 메일은 요청 시 선택한 언어로 발송합니다. 링크·인증 토큰·메일 발송/요청 제한은 그대로입니다.
파일명·기기 이름·계정 표시 이름·메모·URL·전송 원문은 번역하지 않습니다. 사용자 메시지나 HTML을 번역 키로 실행하지 않고 Vue의 일반 텍스트 출력으로 표시합니다. 시스템 문구의 값은 한 번만 치환하므로 원문에 `{p0}` 같은 문자열이 있어도 재해석하지 않습니다.
공통 문구는 `packages/shared/src/messages.js`, 순수 언어 선택/치환 로직은 `packages/shared/src/localization.js`, 브라우저의 선택·날짜 처리는 `apps/web/src/i18n.js`에서 관리합니다. 한국어 원문을 번역 키로 사용하며 새 사용자 문구를 추가하면 영어·일본어와 변수 자리도 함께 추가합니다. 관리자 전용 문구는 이 카탈로그로 옮길 필요가 없습니다. API는 선택한 `Accept-Language`에 맞춰 최상위 시스템 `message`만 번역하며 자료 본문이나 API 코드·상태는 변경하지 않습니다. 기존 언어 헤더가 없는 클라이언트의 API 메시지는 한국어를 유지합니다.
설치 manifest는 언어별 이름/설명을 사용하고 오프라인 안내도 저장된 언어 또는 브라우저 언어로 표시합니다. 개인정보·파일·API 응답을 오프라인 캐시에 넣지 않습니다. OS 자체 클립보드 확인이나 브라우저 권한 창의 언어는 해당 OS/브라우저 설정을 따릅니다.
## 배포와 확인
DB 변경·자료 초기화·새 환경변수·Compose 변경이 없습니다. 같은 운영 Compose로 적용합니다.
```sh
git pull --ff-only origin main
sudo docker compose -f docker-compose.yaml up -d --build
```
`npm test`는 언어 우선순위·신뢰 헤더·번역 누락/변수·선택 언어의 오류와 인증 메일을 검증합니다. `npm run test:e2e:languages`는 세 언어의 기본값, 수동 선택/재접속/다른 탭 동기화, 영어 가입·인증, 영어→일본어 실제 전송, 원문/파일 보존, 보관함·날짜·오류·관리자 한국어·모바일·오프라인 안내를 확인합니다. 실제 메일 검증은 격리 발송 대역을 사용하며 Resend 운영 메일은 발송하지 않습니다.

View File

@@ -240,3 +240,11 @@
- 텍스트·파일·링크와 보관함 재전송의 거절 시 보내기 창을 닫고 상대 이름과 거절 알림을 표시한다. 전송 POST 응답을 일부러 지연하고 먼저 거절한 경우에도 알림/종료를 놓치지 않는 실제 브라우저 검증을 통과했다. 새로고침으로 과거 거절을 다시 알리지 않고, 세 번째 거절 뒤 재요청 제한에는 기존 남은 초 카운트를 표시한다. 거절/종료된 파일 초안의 메모리 참조도 정리한다.
- 모든 화면의 용량을 공통 컴포넌트로 표시하여 천 단위 정수는 선명하게, 소수점은 더 작고 흐리게, 단위 앞에는 시각적 간격을 준다. 기존 표시 단위·계산값·정확한 입력값은 유지한다. 실제 관리자 카드의 `7,687`/`.0`/`GB` 분리·소수점 글자 크기/명암·줄바꿈 방지·단위 간격과 PC/390px 모바일 화면을 확인했다. 게스트 명칭과 `UUID · 이메일/게스트(Guest)`, `국가 · 도시 · IP` 구분점도 확인했다.
- 전체 API 146개, 수신/관리자/클립보드/계정 보관함 브라우저 회귀, 타입 검사·빌드·포맷 검사를 통과했다. 최종 격리 운영 Docker에서 계정·파일·정책·관리 설정 재시작 보존, 8MiB 부분 조각 재개와 다운로드, NAS UID 바인드 저장소·수동 파일 삭제·SQLite integrity를 통과했다. 운영 NAS 자료·Compose·DB 구조 변경은 없다.
## 2026-10-15 한국어·일본어·영어 사용자 화면
- 사용자 화면·버튼/접근성 이름·연결 공간·파일/클립보드·수락/거절·보관함·계정 설정·알림과 사용자 요청 인증/재설정 메일을 세 언어로 제공한다. 관리자 본문·표·관리 기록·용량의 작은 값 표시는 한국어를 유지한다. 기존 한국어 문구를 키로 쓴 공통 카탈로그의 영어/일본어 누락·한국어 잔여·변수 자리 불일치와 한 번만 값 치환을 검사했다. 사용자 이름·파일명·메모·전송 원문에는 번역 함수를 적용하지 않는다.
- 직접 저장한 브라우저 선택을 먼저 적용하고, 없으면 검증된 국가 KR/JP/그 외를 ko/ja/en으로 결정한다. 국가 미확인 때는 지원 브라우저 언어/품질 순서·영어 기본값을 사용한다. 위조 국가 헤더 무시, 불지원/거부 언어 처리와 국가 응답 대기 중 수동 선택 우선을 검증했다. 추가 GPS 요청이나 외부 IP 조회를 하지 않고 관리자 보관 지역 정보도 바꾸지 않는다.
- 실제 브라우저에서 재접속 시 언어/기기 ID 유지, 다른 탭의 언어 변경 후 작성 중 Unicode 원문 보존, 영어 가입/메일/인증 자동 로그인·계정 설정, 영어 연결 공간 생성→일본어 참여, EN→JA 텍스트 수락/미리보기/복사·링크 거절 알림·Unicode 파일명 다운로드와 바이트 보존을 확인했다. 보관함·날짜·오류·문서 lang/설치 manifest와 선택 언어의 오프라인 안내도 검증했다. 한국어 API의 코드·상태·원문 호환은 유지한다.
- 320px 영어 가입/일본어 로그인과 390px 일본어 수신 화면을 확인하고 긴 헤더/보조 버튼의 어색한 줄바꿈을 보완했다. 언어 선택으로 컴포넌트를 다시 만들거나 선택 파일/세션을 초기화하지 않는다. 사용자 알림을 공통 표시해 계정 화면에서 토스트가 겹치지 않게 하고, 표시 중인 시스템 문구도 언어 전환에 맞춰 갱신한다.
- 전체 API 150개·타입 검사·빌드·포맷 검사, 새 언어 통합 브라우저와 기존 파일/링크/클립보드·계정·관리자·레이아웃·근처 탐색·재접속·101MiB 분할 전송·PWA 회귀를 통과했다. 격리 운영 Docker에서 영어/일본어 기본 언어/오류·언어별 manifest와 기존 이름·파일·텍스트·인증·관리 정책 재시작 보존, 8MiB 조각 재개·다운로드·NAS UID 바인드·수동 삭제·SQLite integrity를 확인했다. 실제 Resend 메일·NAS 자료·Compose·DB 구조는 변경하지 않았다.

View File

@@ -24,7 +24,8 @@
"test:e2e:clipboard": "node --import tsx tests/e2e/clipboard.mjs",
"test:e2e:receive": "node --import tsx tests/e2e/receive-controls.mjs",
"test:e2e:reconnect": "node --import tsx tests/e2e/transfer-reconnect.mjs",
"test:e2e:admin": "node --import tsx tests/e2e/admin.mjs"
"test:e2e:admin": "node --import tsx tests/e2e/admin.mjs",
"test:e2e:languages": "node --import tsx tests/e2e/localization.mjs"
},
"engines": {
"node": ">=22.12"

View File

@@ -1,5 +1,13 @@
import { z } from 'zod';
export { classifyClipboard, clipboardCharacterCount } from './clipboard.js';
export {
SUPPORTED_LANGUAGES,
normalizeLanguage,
preferredLanguage,
initialLanguage,
translate,
translateSystemMessage,
} from './localization.js';
export const DEVICE_NAME_MAX_LENGTH = 40;
export const PASSWORD_MIN_LENGTH = 8;
export const devicePatch = z
@@ -116,7 +124,13 @@ export type Space = {
expiresAt: number;
members: { device: Device; status: 'pending' | 'approved' }[];
};
export type SessionResponse = { device: Device; csrfToken: string; expiresAt: number };
export type Language = 'ko' | 'ja' | 'en';
export type SessionResponse = {
device: Device;
csrfToken: string;
expiresAt: number;
defaultLanguage?: Language;
};
export function parseUrls(value: string): { urls: string[]; invalid: string[] } {
const entries = value
.split(/\r?\n/)

View File

@@ -0,0 +1,69 @@
import { messages } from './messages.js';
export const SUPPORTED_LANGUAGES = ['ko', 'ja', 'en'];
export function normalizeLanguage(value) {
const language = typeof value === 'string' ? value.trim().toLowerCase().split(/[-_]/)[0] : '';
return SUPPORTED_LANGUAGES.includes(language) ? language : null;
}
export function preferredLanguage(header, fallback = 'en') {
if (typeof header !== 'string') return fallback;
const choices = header
.slice(0, 1024)
.split(',')
.map((part, order) => {
const [tag, ...parameters] = part.trim().split(';');
const quality = parameters.find((parameter) => parameter.trim().startsWith('q='));
const weight = quality ? Number(quality.trim().slice(2)) : 1;
return { language: normalizeLanguage(tag), weight, order };
});
return (
choices
.filter(({ language, weight }) => language && weight > 0 && weight <= 1)
.sort((a, b) => b.weight - a.weight || a.order - b.order)[0]?.language ?? fallback
);
}
export function initialLanguage(country, browserLanguages) {
if (country === 'KR') return 'ko';
if (country === 'JP') return 'ja';
if (country && /^[A-Z]{2}$/.test(country) && country !== 'XX') return 'en';
return preferredLanguage(browserLanguages);
}
const interpolate = (message, values) =>
message.replace(/\{(p\d+)\}/g, (match, key) =>
Object.hasOwn(values, key) ? String(values[key] ?? '') : match,
);
export function translate(message, language = 'ko', values = {}) {
const text = language === 'ko' ? message : (messages[message]?.[language] ?? message);
return interpolate(text, values);
}
const sourceMessages = new Map(
Object.entries(messages).flatMap(([key, translations]) =>
[key, ...Object.values(translations)].map((text) => [text, key]),
),
);
const patterns = Object.keys(messages)
.filter((key) => /\{p\d+\}/.test(key))
.flatMap((key) =>
[key, ...Object.values(messages[key])].map((text) => {
const names = [...text.matchAll(/\{(p\d+)\}/g)].map((match) => match[1]);
const escaped = text
.split(/\{p\d+\}/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { key, names, pattern: new RegExp('^' + escaped.join('([\\s\\S]*?)') + '$') };
}),
);
// Apply only to system messages, never filenames, device names or transferred text.
export function translateSystemMessage(message, language) {
const source = sourceMessages.get(message);
if (source) return translate(source, language);
for (const { key, names, pattern } of patterns) {
const match = pattern.exec(message);
if (match)
return translate(
key,
language,
Object.fromEntries(names.map((name, i) => [name, match[i + 1]])),
);
}
return message;
}

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,14 @@ try {
`for(let i=0;i<60;i++){try{const r=await fetch('http://127.0.0.1:${port}/api/health');if(r.ok){console.log('ready');break}}catch{}if(i===59)throw Error('not ready');await new Promise(r=>setTimeout(r,250))}`,
);
wait();
if (process.env.HOP_CHECK_LANGUAGES === '1') {
assert.equal(
node(
`const base='http://127.0.0.1:${port}';for(const language of ['en','ja']){const headers={Origin:'https://hop.test','Content-Type':'application/json','Accept-Language':language};const created=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});const session=await created.json();if(!created.ok||session.defaultLanguage!==language)throw Error('wrong initial language');const cookie=created.headers.get('set-cookie').split(';')[0];const invalid=await fetch(base+'/api/device/me',{method:'PATCH',headers:{...headers,Cookie:cookie,'X-CSRF-Token':session.csrfToken},body:JSON.stringify({name:''})});const body=await invalid.json();if(body.code!=='VALIDATION_ERROR'||/[가-힣]/.test(body.message))throw Error('untranslated system message');}const manifest=await(await fetch(base+'/manifest.ja.webmanifest')).json();if(manifest.lang!=='ja')throw Error('missing localized manifest');console.log('languages ready');`,
),
'languages ready',
);
}
const state = JSON.parse(
node(
`const base='http://127.0.0.1:${port}';const r=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test','Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error('session failed');const session=await r.json(),cookie=r.headers.get('set-cookie').split(';')[0];if(!r.headers.get('set-cookie').includes('Secure'))throw Error('insecure cookie');const renamed=await fetch(base+'/api/device/me',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({name:'Docker 저장 검증'})});if(!renamed.ok)throw Error('rename failed');console.log(JSON.stringify({cookie,id:session.device.id}));`,

View File

@@ -57,6 +57,7 @@ try {
const errors = [];
async function page(width = 1280, ip = '8.8.4.4', country = 'KR') {
const context = await browser.newContext({
locale: 'ko-KR',
viewport: { width, height: 900 },
isMobile: width < 600,
hasTouch: width < 600,
@@ -67,6 +68,7 @@ try {
'CF-Region': country === 'CN' ? 'Beijing' : 'Daejeon',
},
});
await context.addInitScript(() => localStorage.setItem('hop-language', 'ko'));
const page = await context.newPage();
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(url);

View File

@@ -28,8 +28,8 @@ try {
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const a = await browser.newContext(),
b = await browser.newContext();
const a = await browser.newContext({ locale: 'ko-KR' }),
b = await browser.newContext({ locale: 'ko-KR' });
const sender = await a.newPage(),
receiver = await b.newPage(),
errors = [];

View File

@@ -32,10 +32,12 @@ try {
: {}),
});
const a = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 1280, height: 900 },
permissions: ['clipboard-read', 'clipboard-write'],
});
const b = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 390, height: 844 },
permissions: ['clipboard-read', 'clipboard-write'],
});

View File

@@ -29,8 +29,12 @@ try {
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const hostContext = await browser.newContext({ viewport: { width: 1440, height: 1050 } }),
const hostContext = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 1440, height: 1050 },
}),
guestContext = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,

View File

@@ -31,7 +31,10 @@ try {
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const context = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
const context = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 1280, height: 1000 },
});
const page = await context.newPage(),
errors = [];
page.on('pageerror', (error) => errors.push(error.message));

302
tests/e2e/localization.mjs Normal file
View File

@@ -0,0 +1,302 @@
import assert from 'node:assert/strict';
import { chromium, expect } from '@playwright/test';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-languages-')),
mails = [];
const runtime = await createApp(
{
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
LOCATION_DISCOVERY_ENABLED: 'false',
CLIENT_IP_SOURCE: 'cloudflare',
TRUSTED_PROXY_CIDRS: '127.0.0.1,::1',
REGISTRATION_MODE: 'open',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
},
{ mailer: async (mail) => mails.push(mail) },
);
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const url = 'http://127.0.0.1:' + runtime.app.server.address().port;
runtime.setDevOrigin(url);
runtime.service.cfg.appUrl = url;
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const errors = [];
async function visitor(country, locale = 'en-US', preference, width = 390) {
const context = await browser.newContext({
locale,
viewport: { width, height: 900 },
isMobile: width < 600,
hasTouch: width < 600,
extraHTTPHeaders: {
'CF-Connecting-IP': '8.8.8.8',
...(country ? { 'CF-IPCountry': country } : {}),
},
});
await context.addInitScript(
({ preference }) => {
if (preference) localStorage.setItem('hop-language', preference);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: async () => '',
writeText: async (text) => {
window.__copied = text;
},
},
});
},
{ preference },
);
const page = await context.newPage();
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(url);
await page.locator('.app-shell[data-online="true"]').waitFor();
await page.locator('.initial-device-loading').waitFor({ state: 'hidden' });
return page;
}
async function language(page, value) {
await page.locator('.language-picker select').selectOption(value);
await expect(page.locator('html')).toHaveAttribute('lang', value);
}
async function noKoreanUI(page) {
const text = await page.evaluate(() => {
const clone = document.body.cloneNode(true);
clone.querySelectorAll('.language-picker,script,style').forEach((el) => el.remove());
return clone.textContent;
});
assert.equal(/[가-힣]/.test(text), false, text);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),
false,
);
assert.equal(
await page.locator('.login-button').evaluate((button) => {
const node = [...button.childNodes].find(
(node) => node.nodeType === 3 && node.textContent.trim(),
);
const range = document.createRange();
range.selectNodeContents(node);
return range.getClientRects().length;
}),
1,
'header login text stays on one line',
);
}
for (const [country, browserLocale, chosen] of [
['KR', 'en-US', 'ko'],
['US', 'ja-JP', 'en'],
[null, 'ja-JP', 'ja'],
[null, 'fr-FR', 'en'],
['JP', 'en-US', 'ja'],
]) {
const page = await visitor(country, browserLocale);
await expect(page.locator('html')).toHaveAttribute('lang', chosen);
if (chosen !== 'ko') await noKoreanUI(page);
await page.context().close();
}
const explicit = await visitor('JP', 'ja-JP', 'en');
await expect(explicit.locator('html')).toHaveAttribute('lang', 'en');
await explicit.context().close();
// A manual choice while the initial country response is in flight wins.
const racing = await browser.newContext({
locale: 'en-US',
extraHTTPHeaders: { 'CF-Connecting-IP': '8.8.8.8', 'CF-IPCountry': 'KR' },
});
const race = await racing.newPage();
let release;
const gate = new Promise((resolve) => (release = resolve));
await race.route('**/api/guest/session', async (route) => {
await gate;
await route.continue();
});
await race.goto(url);
await language(race, 'ja');
release();
await race.locator('.app-shell[data-online="true"]').waitFor();
await expect(race.locator('html')).toHaveAttribute('lang', 'ja');
await racing.close();
const sender = await visitor('US', 'ja-JP', undefined, 1280),
receiver = await visitor('JP', 'en-US');
await expect(sender.locator('.login-button')).toHaveText('Log in');
await expect(receiver.locator('.login-button')).toHaveText('ログイン');
const deviceBefore = await receiver.evaluate(
async () => (await (await fetch('/api/device/me')).json()).device.id,
);
await language(receiver, 'en');
await receiver.reload();
await expect(receiver.locator('html')).toHaveAttribute('lang', 'en');
await receiver.locator('.device-name').waitFor();
assert.equal(
await receiver.evaluate(async () => (await (await fetch('/api/device/me')).json()).device.id),
deviceBefore,
);
await language(receiver, 'ja');
await expect(receiver.locator('link[rel="manifest"]')).toHaveAttribute(
'href',
'/manifest.ja.webmanifest',
);
assert.equal(
(await receiver.evaluate(async () => await (await fetch('/manifest.ja.webmanifest')).json()))
.lang,
'ja',
);
// Registration, mail and verification all use English; administrator content remains Korean.
await sender.locator('.login-button').click();
await sender.getByRole('button', { name: 'Sign up with email', exact: true }).click();
await sender.setViewportSize({ width: 320, height: 900 });
await noKoreanUI(sender);
await sender.screenshot({ path: 'tests/e2e/i18n-register-en-mobile.png', fullPage: true });
await sender.setViewportSize({ width: 1280, height: 900 });
await sender.getByLabel('Email', { exact: true }).fill('hello@noname.company');
await sender.getByLabel('Display name', { exact: true }).fill('Japan 日本語 한글');
await sender.getByLabel('Password', { exact: true }).fill('12345678');
await sender.getByLabel('Confirm password', { exact: true }).fill('12345678');
await sender
.getByRole('button', { name: 'Sign up and get verification email', exact: true })
.click();
await expect(sender.locator('.auth-message')).toContainText('verification email');
assert.equal(mails.at(-1).subject, 'hop. Verify your email');
const token = mails.at(-1).text.match(/#verify=([\w-]+)/)[1];
await sender.goto(url + '/#verify=' + token);
await sender.getByRole('button', { name: 'Verify email', exact: true }).click();
await expect(sender.getByRole('tab', { name: 'Received', exact: true })).toHaveAttribute(
'aria-selected',
'true',
);
await sender.getByRole('button', { name: 'Account settings', exact: true }).click();
const settings = sender.getByRole('dialog', { name: 'Account settings', exact: true });
await settings.getByRole('button', { name: 'Change password', exact: true }).click();
await expect(settings.getByLabel('New password', { exact: true })).toBeVisible();
await settings.getByRole('button', { name: 'Close account settings', exact: true }).click();
await sender.getByRole('button', { name: 'hop. Home', exact: true }).click();
// Create in English and join in Japanese.
await sender.getByRole('button', { name: 'Connect another device', exact: true }).click();
await sender.getByRole('button', { name: 'Create a room', exact: true }).click();
await sender.getByRole('button', { name: 'Create', exact: true }).click();
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
await sender.getByRole('button', { name: 'Close dialog', exact: true }).click();
await receiver.getByRole('button', { name: 'ほかのデバイスと接続', exact: true }).click();
await receiver.getByRole('button', { name: 'ルームに参加', exact: true }).click();
await receiver.getByRole('textbox', { name: '招待コード', exact: true }).fill(code);
await receiver.getByRole('button', { name: '参加する', exact: true }).click();
await receiver.locator('.room-leave').waitFor();
await receiver.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await sender.locator('.peer-card').first().click();
const original = '로그인\n日本語 😀 <script>alert(1)</script> ';
await sender.getByLabel('Text to send', { exact: true }).fill(original);
// Another tab changes language without remounting or replacing the draft.
const other = await sender.context().newPage();
await other.goto(url);
await other.locator('.device-name').waitFor();
await language(other, 'ja');
await expect(sender.getByLabel('送信するテキスト', { exact: true })).toHaveValue(original);
await language(other, 'en');
await other.close();
await expect(sender.getByLabel('Text to send', { exact: true })).toHaveValue(original);
await sender.getByRole('button', { name: 'Send', exact: true }).click();
const request = receiver.getByRole('dialog', { name: '受信の承認リクエスト', exact: true });
await expect(request.locator('.consent-kind')).toHaveText('テキストの受信');
await receiver.screenshot({ path: 'tests/e2e/i18n-request-ja.png', fullPage: true });
await request.getByRole('button', { name: '承認', exact: true }).click();
const received = receiver.getByRole('dialog', { name: '受信したテキスト', exact: true });
await expect(received.getByLabel('受信したテキストの内容', { exact: true })).toHaveText(original);
await received.getByRole('button', { name: 'クリップボードにコピー', exact: true }).click();
assert.equal(await receiver.evaluate(() => window.__copied), original);
await receiver.screenshot({ path: 'tests/e2e/i18n-received-ja.png', fullPage: true });
await received.getByRole('button', { name: '受信したテキストを閉じる', exact: true }).click();
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
await sender.locator('.peer-card').first().click();
await sender.getByLabel('Text to send', { exact: true }).fill('https://example.com/rejected');
await sender.getByRole('button', { name: 'Send', exact: true }).click();
await expect(request.locator('.consent-kind')).toHaveText('リンクの受信');
await request.getByRole('button', { name: '辞退', exact: true }).click();
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
await expect(sender.locator('.toast')).toContainText('declined the transfer');
await sender.locator('.peer-card').first().click();
const filename = '파일 日本語.txt',
bytes = Buffer.from('원문 그대로 日本語 😀');
await sender
.getByLabel('Choose files to send', { exact: true })
.setInputFiles({ name: filename, mimeType: 'text/plain', buffer: bytes });
await sender.getByRole('button', { name: 'Send', exact: true }).click();
await expect(request.locator('.consent-kind')).toHaveText('ファイルの受信');
const downloaded = receiver.waitForEvent('download');
await request.getByRole('button', { name: '承認', exact: true }).click();
const file = await downloaded;
assert.equal(file.suggestedFilename(), filename);
assert.deepEqual(await readFile(await file.path()), bytes);
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
await sender.locator('.login-button').click();
await sender.getByRole('button', { name: 'Save to library', exact: true }).count();
await sender.getByRole('tab', { name: 'Saved', exact: true }).click();
await sender.getByRole('button', { name: 'Save to library', exact: true }).click();
await sender.getByRole('button', { name: 'Save clipboard content', exact: true }).click();
await sender.getByLabel('Text to send', { exact: true }).fill('Keep this content unchanged');
await sender.getByRole('button', { name: 'Save', exact: true }).click();
await sender.locator('.item-card').filter({ hasText: 'Keep this content unchanged' }).waitFor();
await expect(sender.locator('.item-footer')).toContainText('[Expires]');
await language(sender, 'ja');
await expect(sender.locator('.item-footer')).toContainText('[保管期限]');
assert.equal(await sender.locator('.item-body').innerText(), 'Keep this content unchanged');
await sender.screenshot({ path: 'tests/e2e/i18n-library-ja.png', fullPage: true });
await language(sender, 'en');
await sender.screenshot({ path: 'tests/e2e/i18n-library-en.png', fullPage: true });
await sender.getByRole('button', { name: 'More', exact: true }).click();
await sender.getByRole('menuitem', { name: 'Administrator', exact: true }).click();
await expect(sender.locator('.admin-panel')).toHaveAttribute('lang', 'ko');
await expect(sender.locator('.admin-panel h1')).toHaveText('서비스 관리');
await expect(sender.locator('html')).toHaveAttribute('lang', 'en');
// The Japanese login form and server-origin error are also localized.
await receiver.locator('.login-button').click();
await receiver.setViewportSize({ width: 320, height: 900 });
await noKoreanUI(receiver);
await receiver.getByLabel('メールアドレス', { exact: true }).fill('missing@hop.test');
await receiver.getByLabel('パスワード', { exact: true }).fill('12345678');
await receiver
.locator('.account-auth')
.getByRole('button', { name: 'ログイン', exact: true })
.click();
await expect(receiver.locator('.error-toast')).toContainText(
'メールアドレスとパスワードを確認してください。',
);
await receiver.screenshot({ path: 'tests/e2e/i18n-login-ja.png', fullPage: true });
await receiver.evaluate(() => navigator.serviceWorker.ready);
await receiver.context().setOffline(true);
await receiver.reload({ waitUntil: 'domcontentloaded' });
await expect(receiver.getByText('インターネット接続が必要です。', { exact: true })).toBeVisible();
await expect(receiver.locator('html')).toHaveAttribute('lang', 'ja');
await receiver.context().setOffline(false);
assert.deepEqual(errors, []);
console.log(
'PASS: trusted country/browser defaults and explicit preference priority/race/reload, cross-tab language changes preserve device/draft, EN registration/mail/verification/account settings, EN→JA rooms/text/copy/link decline/file download, live localized library/dates/errors/manifest, Korean-only administrator and mobile layouts.',
);
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}

View File

@@ -31,11 +31,13 @@ try {
});
const origin = { latitude: 37.5665, longitude: 126.978, accuracy: 20 };
const a = await browser.newContext({
locale: 'ko-KR',
permissions: ['geolocation'],
geolocation: origin,
viewport: { width: 1280, height: 900 },
});
const b = await browser.newContext({
locale: 'ko-KR',
permissions: ['geolocation'],
geolocation: { ...origin, latitude: origin.latitude + 0.0004 },
viewport: { width: 390, height: 844 },

View File

@@ -33,8 +33,9 @@ try {
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const a = await browser.newContext({ viewport: { width: 1440, height: 1050 } }),
const a = await browser.newContext({ locale: 'ko-KR', viewport: { width: 1440, height: 1050 } }),
b = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
@@ -314,7 +315,7 @@ try {
await sender.close();
await receiver.getByRole('button', { name: 'hop. 홈', exact: true }).click();
await receiver.locator('.peer-card').filter({ hasText: '보내는 Mac' }).waitFor();
const c = await browser.newContext({ viewport: { width: 1366, height: 900 } }),
const c = await browser.newContext({ locale: 'ko-KR', viewport: { width: 1366, height: 900 } }),
urlReceiver = await c.newPage();
urlReceiver.on('pageerror', (error) => errors.push(error.message));
await urlReceiver.goto(url);

View File

@@ -42,8 +42,9 @@ try {
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const a = await browser.newContext({ viewport: { width: 1440, height: 1000 } }),
const a = await browser.newContext({ locale: 'ko-KR', viewport: { width: 1440, height: 1000 } }),
b = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,

View File

@@ -29,6 +29,7 @@ try {
: {}),
});
const context = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 390, height: 844 },
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1',

View File

@@ -32,8 +32,8 @@ try {
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const a = await browser.newContext({ viewport: { width: 1280, height: 900 } }),
b = await browser.newContext({ viewport: { width: 390, height: 844 } });
const a = await browser.newContext({ locale: 'ko-KR', viewport: { width: 1280, height: 900 } }),
b = await browser.newContext({ locale: 'ko-KR', viewport: { width: 390, height: 844 } });
const sender = await a.newPage(),
receiver = await b.newPage(),
errors = [];

View File

@@ -32,8 +32,9 @@ try {
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const a = await browser.newContext({ viewport: { width: 1280, height: 900 } }),
const a = await browser.newContext({ locale: 'ko-KR', viewport: { width: 1280, height: 900 } }),
b = await browser.newContext({
locale: 'ko-KR',
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,