From 4eee4d2a61650abef5b5ef94a223d293c4400382 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 23 Aug 2026 13:16:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Gateway=20=EC=9B=B9=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=84=A4=EC=A0=95=EA=B3=BC=20PWA=20=EA=B5=AC?= =?UTF-8?q?=EB=8F=85=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계정 화면에서 profile별 9개 알림을 기본 꺼짐으로 설정하고 Android, iPhone 홈 화면 앱, Windows 브라우저용 service worker 구독 흐름을 제공한다. 전송 비활성 상태와 모바일 Chromium 경계를 E2E로 검증한다. --- .../e2e/account-icon-sync.spec.ts | 20 + .../e2e/playwright.config.mjs | 4 +- .../e2e/web-push-settings.spec.ts | 155 +++++++ app/gateway-frontend/index.html | 4 + .../public/manifest.webmanifest | 20 + app/gateway-frontend/public/sw.js | 50 +++ app/gateway-frontend/public/web-push-icon.svg | 5 + app/gateway-frontend/src/main.ts | 8 + .../src/views/AccountView.vue | 386 ++++++++++++++++++ 9 files changed, 650 insertions(+), 2 deletions(-) create mode 100644 app/gateway-frontend/e2e/web-push-settings.spec.ts create mode 100644 app/gateway-frontend/public/manifest.webmanifest create mode 100644 app/gateway-frontend/public/sw.js create mode 100644 app/gateway-frontend/public/web-push-icon.svg diff --git a/app/gateway-frontend/e2e/account-icon-sync.spec.ts b/app/gateway-frontend/e2e/account-icon-sync.spec.ts index 0f3ba146..32dcc53c 100644 --- a/app/gateway-frontend/e2e/account-icon-sync.spec.ts +++ b/app/gateway-frontend/e2e/account-icon-sync.spec.ts @@ -111,6 +111,26 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => { 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') { return response({ ok: true, diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index adfb262c..93988a7c 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -15,6 +15,7 @@ export default defineConfig({ 'lobby-game-auth.spec.ts', 'logout.spec.ts', 'account-icon-sync.spec.ts', + 'web-push-settings.spec.ts', 'legacy-log-html.spec.ts', 'gateway-notice-html.spec.ts', 'kakao-otp.spec.ts', @@ -40,8 +41,7 @@ export default defineConfig({ screenshot: 'only-on-failure', }, webServer: { - 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}`, + 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}`, cwd: repositoryRoot, url: `http://127.0.0.1:${port}/gateway/`, reuseExistingServer: false, diff --git a/app/gateway-frontend/e2e/web-push-settings.spec.ts b/app/gateway-frontend/e2e/web-push-settings.spec.ts new file mode 100644 index 00000000..e885531f --- /dev/null +++ b/app/gateway-frontend/e2e/web-push-settings.spec.ts @@ -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 => { + const body = JSON.parse(route.request().postData() ?? '{}') as Record< + string, + { json?: Record } | Record + >; + const input = body[String(index)] ?? ({} as Record); + return ('json' in input && input.json ? input.json : input) as Record; +}; + +const installFixture = async (page: Page) => { + const saved: Record[] = []; + 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 }); +}); diff --git a/app/gateway-frontend/index.html b/app/gateway-frontend/index.html index f62aeb01..a1880d8d 100644 --- a/app/gateway-frontend/index.html +++ b/app/gateway-frontend/index.html @@ -4,6 +4,10 @@ + + + + 삼국지 모의전투 HiDCHe - Gateway diff --git a/app/gateway-frontend/public/manifest.webmanifest b/app/gateway-frontend/public/manifest.webmanifest new file mode 100644 index 00000000..a56fa32d --- /dev/null +++ b/app/gateway-frontend/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/app/gateway-frontend/public/sw.js b/app/gateway-frontend/public/sw.js new file mode 100644 index 00000000..abf17be4 --- /dev/null +++ b/app/gateway-frontend/public/sw.js @@ -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); + }) + ); +}); diff --git a/app/gateway-frontend/public/web-push-icon.svg b/app/gateway-frontend/public/web-push-icon.svg new file mode 100644 index 00000000..e54f929c --- /dev/null +++ b/app/gateway-frontend/public/web-push-icon.svg @@ -0,0 +1,5 @@ + + + + 삼모 + diff --git a/app/gateway-frontend/src/main.ts b/app/gateway-frontend/src/main.ts index 8c8591d0..55c8639c 100644 --- a/app/gateway-frontend/src/main.ts +++ b/app/gateway-frontend/src/main.ts @@ -13,3 +13,11 @@ app.use(createPinia()); app.use(router); 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, + }); + }); +} diff --git a/app/gateway-frontend/src/views/AccountView.vue b/app/gateway-frontend/src/views/AccountView.vue index cc0ca927..b607ee05 100644 --- a/app/gateway-frontend/src/views/AccountView.vue +++ b/app/gateway-frontend/src/views/AccountView.vue @@ -8,10 +8,19 @@ import DefaultLayout from '../layouts/DefaultLayout.vue'; import { createGameTrpc } from '../utils/gameTrpc'; import { trpc } from '../utils/trpc'; import { sealPassword } from '../utils/passwordEnvelope'; +import type { WebPushEventType } from '@sammo-ts/common'; type Account = Awaited>; type IconSyncProfile = Awaited>['profiles'][number]; type IconSyncState = 'idle' | 'pending' | 'success' | 'error'; +type NotificationState = Awaited>; +type LocalNotificationPreference = { + profileName: string; + eventType: WebPushEventType; + enabled: boolean; + targetYear: number | null; + targetMonth: number | null; +}; type IconSyncRow = IconSyncProfile & { selected: boolean; state: IconSyncState; @@ -40,12 +49,204 @@ const iconServerStaticFeedback = ref(false); const iconServerMessage = ref(''); const iconServerRows = ref([]); const iconServerDialog = ref(null); +const notificationState = ref(null); +const notificationPreferences = ref([]); +const selectedNotificationProfile = ref(''); +const notificationBusy = ref(false); +const notificationPermission = ref('default'); +const currentPushSubscription = ref(null); let iconServerReturnFocus: HTMLElement | null = null; let previousBodyOverflow = ''; let iconServerStaticTimer: ReturnType | null = null; +const notificationLabels: Record = { + 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 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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(() => { if (!account.value) return '-'; if (account.value.roles.some((role) => role.includes('admin') || role === 'superuser')) return '관리자'; @@ -79,6 +280,7 @@ const loadAccount = async (): Promise => { } try { account.value = await trpc.account.get.query({ sessionToken: token }); + await loadNotificationSettings(); } catch (error) { errorMessage.value = error instanceof Error ? error.message : '계정 정보를 불러오지 못했습니다.'; } finally { @@ -590,6 +792,117 @@ onBeforeUnmount(() => { + + + + + + + + + + + + + + + + + + + + +
웹 알림 설정
전송 상태 + {{ + notificationState.capability.enabled ? '사용 가능' : '준비됨 · 운영 비활성' + }} +

+ 전송 기능은 아직 운영 설정에서 꺼져 있습니다. 개별 설정은 저장되지만 실제 알림은 + 발송되지 않습니다. +

+

+ 권한: {{ notificationPermission }} · 등록 기기 + {{ notificationState.subscriptionCount }}대 +

+

+ iPhone은 Safari 공유 메뉴의 ‘홈 화면에 추가’ 후, 설치된 아이콘으로 열어야 알림을 켤 수 + 있습니다. +

+ + +
서버 + + {{ + currentProfile.status + }} +
{{ notificationLabels[eventType] }} + + + + 년 + + 월 + +
@@ -717,6 +1030,65 @@ onBeforeUnmount(() => { 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 { background-color: #302016; background-image: var(--sammo-texture-walnut, url('https://sam-image.hided.net/game/back_walnut.jpg')); @@ -1138,6 +1510,20 @@ onBeforeUnmount(() => { 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 { padding-right: 8px; padding-left: 8px;