feat: Gateway 웹 알림 설정과 PWA 구독 화면을 추가한다

계정 화면에서 profile별 9개 알림을 기본 꺼짐으로 설정하고 Android, iPhone 홈 화면 앱, Windows 브라우저용 service worker 구독 흐름을 제공한다. 전송 비활성 상태와 모바일 Chromium 경계를 E2E로 검증한다.
This commit is contained in:
2026-08-23 13:16:14 +00:00
parent 7bc3213b03
commit 4eee4d2a61
9 changed files with 650 additions and 2 deletions
@@ -111,6 +111,26 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
deleteAfter: null, deleteAfter: null,
}); });
} }
if (operation === 'account.notifications.get') {
return response({
capability: { enabled: false, publicKey: null },
eventTypes: [
'TROOP_ANNIHILATED',
'PRIVATE_MESSAGE_RECEIVED',
'AUTONOMOUS_ACTION_ENDED',
'RESERVED_TURNS_ENDED',
'PROFILE_PREOPENED',
'PROFILE_OPEN_SCHEDULED',
'PROFILE_OPENED',
'NATION_DESTROYED',
'TARGET_DATE_REACHED',
],
profiles: [],
preferences: [],
subscriptionCount: 0,
currentDeviceSubscribed: false,
});
}
if (operation === 'account.changeIcon') { if (operation === 'account.changeIcon') {
return response({ return response({
ok: true, ok: true,
@@ -15,6 +15,7 @@ export default defineConfig({
'lobby-game-auth.spec.ts', 'lobby-game-auth.spec.ts',
'logout.spec.ts', 'logout.spec.ts',
'account-icon-sync.spec.ts', 'account-icon-sync.spec.ts',
'web-push-settings.spec.ts',
'legacy-log-html.spec.ts', 'legacy-log-html.spec.ts',
'gateway-notice-html.spec.ts', 'gateway-notice-html.spec.ts',
'kakao-otp.spec.ts', 'kakao-otp.spec.ts',
@@ -40,8 +41,7 @@ export default defineConfig({
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
}, },
webServer: { webServer: {
command: command: `export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port ${port}`,
`export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot, cwd: repositoryRoot,
url: `http://127.0.0.1:${port}/gateway/`, url: `http://127.0.0.1:${port}/gateway/`,
reuseExistingServer: false, reuseExistingServer: false,
@@ -0,0 +1,155 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const inputAt = (route: Route, index: number): Record<string, unknown> => {
const body = JSON.parse(route.request().postData() ?? '{}') as Record<
string,
{ json?: Record<string, unknown> } | Record<string, unknown>
>;
const input = body[String(index)] ?? ({} as Record<string, unknown>);
return ('json' in input && input.json ? input.json : input) as Record<string, unknown>;
};
const installFixture = async (page: Page) => {
const saved: Record<string, unknown>[] = [];
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'web-push-session');
});
await page.route('**/gateway/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation, index) => {
if (operation === 'account.get') {
return response({
id: '11111111-1111-4111-8111-111111111111',
username: 'push-user',
displayName: '알림 사용자',
roles: ['user'],
oauthType: 'NONE',
createdAt: '2026-08-23T00:00:00.000Z',
iconUrl: null,
icons: [],
preferredPicture: 'default.jpg',
maxActiveIcons: 5,
nextUploadAt: null,
nextRetireAt: null,
thirdPartyUse: false,
deleteAfter: null,
});
}
if (operation === 'account.notifications.get') {
return response({
capability: { enabled: false, publicKey: null },
eventTypes: [
'TROOP_ANNIHILATED',
'PRIVATE_MESSAGE_RECEIVED',
'AUTONOMOUS_ACTION_ENDED',
'RESERVED_TURNS_ENDED',
'PROFILE_PREOPENED',
'PROFILE_OPEN_SCHEDULED',
'PROFILE_OPENED',
'NATION_DESTROYED',
'TARGET_DATE_REACHED',
],
profiles: [
{
profileName: 'hwe:default',
profile: 'hwe',
currentScenario: 'default',
status: 'RUNNING',
},
],
preferences: [],
subscriptionCount: 0,
currentDeviceSubscribed: false,
});
}
if (operation === 'account.notifications.setPreference') {
saved.push(inputAt(route, index));
return response({ ok: true });
}
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return saved;
};
test('web push settings are default-off and remain configurable while delivery is disabled', async ({
page,
}, testInfo) => {
const saved = await installFixture(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/gateway/account');
const table = page.locator('#notification-table');
await expect(table).toBeVisible();
await expect(table).toContainText('준비됨 · 운영 비활성');
await expect(page.getByRole('button', { name: '이 기기 알림 켜기' })).toBeDisabled();
const checkboxes = table.getByRole('checkbox');
await expect(checkboxes).toHaveCount(9);
for (let index = 0; index < 9; index += 1) await expect(checkboxes.nth(index)).not.toBeChecked();
await table.getByRole('checkbox', { name: '알림 받기' }).nth(1).check();
await expect.poll(() => saved.length).toBe(1);
expect(saved[0]).toMatchObject({
profileName: 'hwe:default',
eventType: 'PRIVATE_MESSAGE_RECEIVED',
enabled: true,
});
const profileSelect = table.locator('select');
await profileSelect.focus();
expect(await profileSelect.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none');
const bounds = await table.boundingBox();
expect(bounds?.width).toBe(550);
const serviceWorkerScope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope);
expect(serviceWorkerScope).toBe('http://127.0.0.1:15130/gateway/');
const pwaAssets = await page.evaluate(async () => {
const [manifest, worker] = await Promise.all([fetch('/gateway/manifest.webmanifest'), fetch('/gateway/sw.js')]);
return {
manifestStatus: manifest.status,
manifestType: manifest.headers.get('content-type'),
manifestBody: await manifest.json(),
workerStatus: worker.status,
workerType: worker.headers.get('content-type'),
};
});
expect(pwaAssets).toMatchObject({
manifestStatus: 200,
manifestBody: { start_url: './', scope: './', display: 'standalone' },
workerStatus: 200,
});
expect(pwaAssets.manifestType).toContain('application/manifest+json');
expect(pwaAssets.workerType).toContain('javascript');
await page.screenshot({ path: testInfo.outputPath('web-push-settings-desktop.png'), fullPage: true });
});
test('web push settings fit a mobile Chromium viewport and show the iPhone install prerequisite', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
configurable: true,
});
});
await installFixture(page);
await page.goto('/gateway/account');
const table = page.locator('#notification-table');
await expect(table).toContainText('홈 화면에 추가');
const bounds = await table.boundingBox();
expect(bounds?.x).toBe(0);
expect(bounds?.width).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath('web-push-settings-mobile.png'), fullPage: true });
});
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#172a52" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="삼모" />
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
<title>삼국지 모의전투 HiDCHe - Gateway</title> <title>삼국지 모의전투 HiDCHe - Gateway</title>
</head> </head>
<body class="bg-black text-white"> <body class="bg-black text-white">
@@ -0,0 +1,20 @@
{
"name": "삼국지 모의전투 HiDCHe",
"short_name": "삼모",
"description": "삼국지 모의전투 Gateway",
"lang": "ko",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#172a52",
"icons": [
{
"src": "web-push-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+50
View File
@@ -0,0 +1,50 @@
const ALLOWED_NOTIFICATION_PATH = /^\/(?:gateway|che|hwe|kwe|pwe|twe|nya|pya)(?:\/|$)/u;
const safeNotificationUrl = (value) => {
try {
const url = new URL(typeof value === 'string' ? value : '/gateway/', self.location.origin);
if (url.origin !== self.location.origin || !ALLOWED_NOTIFICATION_PATH.test(url.pathname)) {
return '/gateway/';
}
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return '/gateway/';
}
};
self.addEventListener('push', (event) => {
let payload;
try {
payload = event.data?.json() ?? {};
} catch {
payload = {};
}
const title = typeof payload.title === 'string' ? payload.title : '삼국지 모의전투';
const body = typeof payload.body === 'string' ? payload.body : '새 알림이 있습니다.';
const tag = typeof payload.tag === 'string' ? payload.tag : 'sammo-notification';
event.waitUntil(
self.registration.showNotification(title, {
body,
tag,
icon: './web-push-icon.svg',
badge: './web-push-icon.svg',
data: { url: safeNotificationUrl(payload.url) },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetPath = safeNotificationUrl(event.notification.data?.url);
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => {
const targetUrl = new URL(targetPath, self.location.origin).href;
for (const client of clients) {
if (new URL(client.url).origin !== self.location.origin) continue;
await client.navigate(targetUrl);
return client.focus();
}
return self.clients.openWindow(targetUrl);
})
);
});
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" role="img" aria-label="삼모">
<rect width="192" height="192" rx="32" fill="#172a52"/>
<rect x="12" y="12" width="168" height="168" rx="24" fill="none" stroke="#d6b25e" stroke-width="8"/>
<text x="96" y="119" fill="#fff" font-family="serif" font-size="72" font-weight="700" text-anchor="middle">삼모</text>
</svg>

After

Width:  |  Height:  |  Size: 385 B

+8
View File
@@ -13,3 +13,11 @@ app.use(createPinia());
app.use(router); app.use(router);
app.mount('#app'); app.mount('#app');
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`, {
scope: import.meta.env.BASE_URL,
});
});
}
@@ -8,10 +8,19 @@ import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc } from '../utils/gameTrpc'; import { createGameTrpc } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { sealPassword } from '../utils/passwordEnvelope'; import { sealPassword } from '../utils/passwordEnvelope';
import type { WebPushEventType } from '@sammo-ts/common';
type Account = Awaited<ReturnType<typeof trpc.account.get.query>>; type Account = Awaited<ReturnType<typeof trpc.account.get.query>>;
type IconSyncProfile = Awaited<ReturnType<typeof trpc.account.changeIcon.mutate>>['profiles'][number]; type IconSyncProfile = Awaited<ReturnType<typeof trpc.account.changeIcon.mutate>>['profiles'][number];
type IconSyncState = 'idle' | 'pending' | 'success' | 'error'; type IconSyncState = 'idle' | 'pending' | 'success' | 'error';
type NotificationState = Awaited<ReturnType<typeof trpc.account.notifications.get.query>>;
type LocalNotificationPreference = {
profileName: string;
eventType: WebPushEventType;
enabled: boolean;
targetYear: number | null;
targetMonth: number | null;
};
type IconSyncRow = IconSyncProfile & { type IconSyncRow = IconSyncProfile & {
selected: boolean; selected: boolean;
state: IconSyncState; state: IconSyncState;
@@ -40,12 +49,204 @@ const iconServerStaticFeedback = ref(false);
const iconServerMessage = ref(''); const iconServerMessage = ref('');
const iconServerRows = ref<IconSyncRow[]>([]); const iconServerRows = ref<IconSyncRow[]>([]);
const iconServerDialog = ref<HTMLElement | null>(null); const iconServerDialog = ref<HTMLElement | null>(null);
const notificationState = ref<NotificationState | null>(null);
const notificationPreferences = ref<LocalNotificationPreference[]>([]);
const selectedNotificationProfile = ref('');
const notificationBusy = ref(false);
const notificationPermission = ref<NotificationPermission | 'unsupported'>('default');
const currentPushSubscription = ref<PushSubscription | null>(null);
let iconServerReturnFocus: HTMLElement | null = null; let iconServerReturnFocus: HTMLElement | null = null;
let previousBodyOverflow = ''; let previousBodyOverflow = '';
let iconServerStaticTimer: ReturnType<typeof setTimeout> | null = null; let iconServerStaticTimer: ReturnType<typeof setTimeout> | null = null;
const notificationLabels: Record<WebPushEventType, string> = {
TROOP_ANNIHILATED: '내 병력 전멸',
PRIVATE_MESSAGE_RECEIVED: '개인 메시지 수신',
AUTONOMOUS_ACTION_ENDED: '자율행동 종료',
RESERVED_TURNS_ENDED: '예턴 종료',
PROFILE_PREOPENED: '서버 가오픈',
PROFILE_OPEN_SCHEDULED: '서버 오픈 예약',
PROFILE_OPENED: '서버 오픈',
NATION_DESTROYED: '내 국가 멸망',
TARGET_DATE_REACHED: '특정 연월 도달',
};
const supportsWebPush = computed(
() => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
);
const isIos = computed(() => /iPad|iPhone|iPod/u.test(navigator.userAgent));
const isStandalone = computed(
() =>
window.matchMedia('(display-mode: standalone)').matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
);
const currentProfile = computed(() =>
notificationState.value?.profiles.find((profile) => profile.profileName === selectedNotificationProfile.value)
);
const notificationEventTypes = computed(
() => (notificationState.value?.eventTypes ?? []) as readonly WebPushEventType[]
);
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token'); const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
const ensureNotificationPreference = (eventType: WebPushEventType): LocalNotificationPreference => {
const profileName = selectedNotificationProfile.value;
let preference = notificationPreferences.value.find(
(candidate) => candidate.profileName === profileName && candidate.eventType === eventType
);
if (!preference) {
preference = {
profileName,
eventType,
enabled: false,
targetYear: null,
targetMonth: null,
};
notificationPreferences.value.push(preference);
}
return preference;
};
const loadNotificationSettings = async (): Promise<void> => {
const token = sessionToken();
if (!token) return;
let endpoint: string | undefined;
if (supportsWebPush.value) {
notificationPermission.value = Notification.permission;
const registration = await navigator.serviceWorker.getRegistration(import.meta.env.BASE_URL);
currentPushSubscription.value = (await registration?.pushManager.getSubscription()) ?? null;
endpoint = currentPushSubscription.value?.endpoint;
} else {
notificationPermission.value = 'unsupported';
}
const state = await trpc.account.notifications.get.query({
sessionToken: token,
...(endpoint ? { currentEndpoint: endpoint } : {}),
});
notificationState.value = state;
notificationPreferences.value = state.preferences.map((preference) => ({
profileName: preference.profileName,
eventType: preference.eventType as WebPushEventType,
enabled: preference.enabled,
targetYear: preference.targetYear,
targetMonth: preference.targetMonth,
}));
if (
!selectedNotificationProfile.value ||
!state.profiles.some((profile) => profile.profileName === selectedNotificationProfile.value)
) {
selectedNotificationProfile.value = state.profiles[0]?.profileName ?? '';
}
};
const base64UrlToUint8Array = (value: string): Uint8Array<ArrayBuffer> => {
const padding = '='.repeat((4 - (value.length % 4)) % 4);
const raw = window.atob((value + padding).replace(/-/gu, '+').replace(/_/gu, '/'));
const result = new Uint8Array(new ArrayBuffer(raw.length));
for (let index = 0; index < raw.length; index += 1) result[index] = raw.charCodeAt(index);
return result;
};
const subscribeCurrentDevice = async (): Promise<void> => {
if (notificationBusy.value || !notificationState.value?.capability.enabled) return;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
const publicKey = notificationState.value.capability.publicKey;
if (!token || !publicKey) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.');
if (!supportsWebPush.value) throw new Error('이 브라우저는 Web Push를 지원하지 않습니다.');
if (isIos.value && !isStandalone.value) {
throw new Error('iPhone에서는 Safari의 공유 메뉴에서 홈 화면에 추가한 뒤 그 아이콘으로 열어 주세요.');
}
const permission = await Notification.requestPermission();
notificationPermission.value = permission;
if (permission !== 'granted') throw new Error('브라우저 알림 권한이 허용되지 않았습니다.');
const registration = await navigator.serviceWorker.ready;
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToUint8Array(publicKey),
}));
const json = subscription.toJSON();
if (!json.endpoint || !json.keys?.p256dh || !json.keys.auth) {
throw new Error('브라우저가 올바른 Push 구독 정보를 반환하지 않았습니다.');
}
await trpc.account.notifications.subscribe.mutate({
sessionToken: token,
subscription: {
endpoint: json.endpoint,
expirationTime: subscription.expirationTime,
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
},
});
currentPushSubscription.value = subscription;
notificationState.value = {
...notificationState.value,
currentDeviceSubscribed: true,
subscriptionCount: notificationState.value.subscriptionCount + 1,
};
successMessage.value = '이 기기의 웹 알림을 등록했습니다.';
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '이 기기의 웹 알림을 등록하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const unsubscribeCurrentDevice = async (): Promise<void> => {
if (notificationBusy.value || !currentPushSubscription.value) return;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
const endpoint = currentPushSubscription.value.endpoint;
await trpc.account.notifications.unsubscribe.mutate({ sessionToken: token, endpoint });
await currentPushSubscription.value.unsubscribe();
currentPushSubscription.value = null;
if (notificationState.value) {
notificationState.value = {
...notificationState.value,
currentDeviceSubscribed: false,
subscriptionCount: Math.max(0, notificationState.value.subscriptionCount - 1),
};
}
successMessage.value = '이 기기의 웹 알림을 해제했습니다.';
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '이 기기의 웹 알림을 해제하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const saveNotificationPreference = async (eventType: WebPushEventType, revertToggle = false): Promise<void> => {
if (notificationBusy.value || !selectedNotificationProfile.value) return;
const preference = ensureNotificationPreference(eventType);
const previousEnabled = revertToggle ? !preference.enabled : preference.enabled;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
await trpc.account.notifications.setPreference.mutate({
sessionToken: token,
profileName: selectedNotificationProfile.value,
eventType,
enabled: preference.enabled,
targetYear: preference.targetYear,
targetMonth: preference.targetMonth,
});
successMessage.value = `${notificationLabels[eventType]} 알림 설정을 저장했습니다.`;
} catch (error) {
preference.enabled = previousEnabled;
errorMessage.value = error instanceof Error ? error.message : '알림 설정을 저장하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const gradeLabel = computed(() => { const gradeLabel = computed(() => {
if (!account.value) return '-'; if (!account.value) return '-';
if (account.value.roles.some((role) => role.includes('admin') || role === 'superuser')) return '관리자'; if (account.value.roles.some((role) => role.includes('admin') || role === 'superuser')) return '관리자';
@@ -79,6 +280,7 @@ const loadAccount = async (): Promise<void> => {
} }
try { try {
account.value = await trpc.account.get.query({ sessionToken: token }); account.value = await trpc.account.get.query({ sessionToken: token });
await loadNotificationSettings();
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : '계정 정보를 불러오지 못했습니다.'; errorMessage.value = error instanceof Error ? error.message : '계정 정보를 불러오지 못했습니다.';
} finally { } finally {
@@ -590,6 +792,117 @@ onBeforeUnmount(() => {
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
<table v-if="notificationState" id="notification-table" class="legacy-bg0">
<thead>
<tr>
<th colspan="2" class="legacy-bg1"> 알림 설정</th>
</tr>
</thead>
<tbody>
<tr>
<th class="legacy-bg1 notification-label">전송 상태</th>
<td class="notification-copy">
<strong>{{
notificationState.capability.enabled ? '사용 가능' : '준비됨 · 운영 비활성'
}}</strong>
<p v-if="!notificationState.capability.enabled">
전송 기능은 아직 운영 설정에서 꺼져 있습니다. 개별 설정은 저장되지만 실제 알림은
발송되지 않습니다.
</p>
<p v-else>
권한: {{ notificationPermission }} · 등록 기기
{{ notificationState.subscriptionCount }}
</p>
<p v-if="isIos && !isStandalone">
iPhone은 Safari 공유 메뉴의 화면에 추가 , 설치된 아이콘으로 열어야 알림을
있습니다.
</p>
<button
v-if="currentPushSubscription"
class="skin-button"
type="button"
:disabled="notificationBusy"
@click="unsubscribeCurrentDevice"
>
기기 알림 해제
</button>
<button
v-else
class="skin-button"
type="button"
:disabled="
notificationBusy || !notificationState.capability.enabled || !supportsWebPush
"
@click="subscribeCurrentDevice"
>
기기 알림 켜기
</button>
</td>
</tr>
<tr>
<th class="legacy-bg1 notification-label">서버</th>
<td>
<select
v-model="selectedNotificationProfile"
class="skin-input notification-profile-select"
>
<option
v-for="profile in notificationState.profiles"
:key="profile.profileName"
:value="profile.profileName"
>
{{ profile.profile }} · {{ profile.currentScenario ?? profile.profileName }}
</option>
</select>
<span v-if="currentProfile" class="notification-profile-status">{{
currentProfile.status
}}</span>
</td>
</tr>
<tr v-for="eventType in notificationEventTypes" :key="eventType">
<th class="legacy-bg1 notification-label">{{ notificationLabels[eventType] }}</th>
<td class="notification-preference">
<label>
<input
v-model="ensureNotificationPreference(eventType).enabled"
type="checkbox"
:disabled="notificationBusy || !selectedNotificationProfile"
@change="saveNotificationPreference(eventType, true)"
/>
알림 받기
</label>
<span v-if="eventType === 'TARGET_DATE_REACHED'" class="target-date-fields">
<input
v-model.number="ensureNotificationPreference(eventType).targetYear"
class="skin-input target-year"
type="number"
min="0"
max="9999"
aria-label="목표 연도"
@change="
ensureNotificationPreference(eventType).enabled &&
saveNotificationPreference(eventType)
"
/>
<input
v-model.number="ensureNotificationPreference(eventType).targetMonth"
class="skin-input target-month"
type="number"
min="1"
max="12"
aria-label="목표 "
@change="
ensureNotificationPreference(eventType).enabled &&
saveNotificationPreference(eventType)
"
/>
</span>
</td>
</tr>
</tbody>
</table>
<p v-if="successMessage" class="feedback success" role="status">{{ successMessage }}</p> <p v-if="successMessage" class="feedback success" role="status">{{ successMessage }}</p>
<p v-if="errorMessage" class="feedback error" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="feedback error" role="alert">{{ errorMessage }}</p>
</div> </div>
@@ -717,6 +1030,65 @@ onBeforeUnmount(() => {
text-align: center; text-align: center;
} }
#notification-table {
width: 100%;
margin-top: 16px;
border: 1px solid gray;
border-spacing: 0;
table-layout: fixed;
}
#notification-table th,
#notification-table td {
border: 1px solid;
border-color: gray #000 #000 gray;
padding: 6px;
}
.notification-label {
width: 150px;
text-align: center;
}
.notification-copy {
text-align: left;
}
.notification-copy p {
margin: 4px 0;
color: #ddd;
line-height: 1.4;
}
.notification-profile-select {
width: min(310px, calc(100% - 80px));
min-height: 28px;
}
.notification-profile-status {
margin-left: 8px;
color: #bbb;
}
.notification-preference {
text-align: left;
}
.target-date-fields {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 18px;
}
.target-year {
width: 72px;
}
.target-month {
width: 48px;
}
.legacy-bg0 { .legacy-bg0 {
background-color: #302016; background-color: #302016;
background-image: var(--sammo-texture-walnut, url('https://sam-image.hided.net/game/back_walnut.jpg')); background-image: var(--sammo-texture-walnut, url('https://sam-image.hided.net/game/back_walnut.jpg'));
@@ -1138,6 +1510,20 @@ onBeforeUnmount(() => {
margin-left: 0; margin-left: 0;
} }
#notification-table {
width: 100vw;
max-width: 100vw;
}
.notification-label {
width: 118px;
}
.target-date-fields {
flex-wrap: wrap;
margin: 6px 0 0;
}
.icon-server-backdrop { .icon-server-backdrop {
padding-right: 8px; padding-right: 8px;
padding-left: 8px; padding-left: 8px;