feat: Gateway 웹 알림 설정과 PWA 구독 화면을 추가한다
계정 화면에서 profile별 9개 알림을 기본 꺼짐으로 설정하고 Android, iPhone 홈 화면 앱, Windows 브라우저용 service worker 구독 흐름을 제공한다. 전송 비활성 상태와 모바일 Chromium 경계를 E2E로 검증한다.
This commit is contained in:
@@ -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,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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user