chore: test deploy snapshot

This commit is contained in:
2026-05-27 10:43:01 +09:00
parent c1d0f4c1db
commit 4c4b3c8d2c
78 changed files with 10392 additions and 2301 deletions

View File

@@ -1,5 +1,5 @@
import { Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import './AppsLibraryView.css';
import { BaseballTicketBayPlayAppView } from '../baseball-ticket-bay/BaseballTicketBayPlayAppView';
@@ -10,6 +10,7 @@ import { TheQuestAppView } from '../the-quest/TheQuestAppView';
import { TetrisAppView } from '../tetris/TetrisAppView';
import { APP_LIBRARY_ENTRIES, findReadyPlayAppEntryById } from './appsRegistry';
import { buildPlayAppPath } from '../../../../app/main/routes';
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../../../../app/main/pwa/installManifest';
function normalizeReturnToPath(returnTo: string | null) {
if (!returnTo || !returnTo.startsWith('/') || returnTo.startsWith('//')) {
@@ -19,6 +20,23 @@ function normalizeReturnToPath(returnTo: string | null) {
return returnTo;
}
function resolvePlayAppInstallThemeColor(appId: string) {
switch (appId) {
case 'baseball-ticket-bay':
return '#1b3f91';
case 'photoprism':
return '#0f766e';
case 'photo-puzzle':
return '#d97706';
case 'the-quest':
return '#7c3aed';
case 'tetris':
return '#0f172a';
default:
return '#165dff';
}
}
export function AppsLibraryView() {
const location = useLocation();
const navigate = useNavigate();
@@ -51,6 +69,35 @@ export function AppsLibraryView() {
};
}, []);
useLayoutEffect(() => {
if (typeof window === 'undefined' || !activeAppEntry || activeAppEntry.id === 'e-reader') {
return undefined;
}
const startPath = `${location.pathname}${location.search}${location.hash}`;
const manifestObjectUrl = createInstallManifestObjectUrl({
startPath,
scope: location.pathname,
name: activeAppEntry.name,
shortName: activeAppEntry.name,
description: `${activeAppEntry.name} 앱을 홈 화면에서 바로 엽니다.`,
themeColor: resolvePlayAppInstallThemeColor(activeAppEntry.id),
backgroundColor: '#eff5ff',
});
const restoreManifest = swapInstallDocumentMetadata({
manifestHref: manifestObjectUrl,
title: activeAppEntry.name,
themeColor: resolvePlayAppInstallThemeColor(activeAppEntry.id),
});
return () => {
restoreManifest();
if (manifestObjectUrl) {
window.URL.revokeObjectURL(manifestObjectUrl);
}
};
}, [activeAppEntry, location.hash, location.pathname, location.search]);
const openApp = (appId: string) => {
const currentPath = `${location.pathname}${location.search}${location.hash}`;
setSearchParams(new URLSearchParams(buildPlayAppPath(appId, 'embedded', currentPath).split('?')[1] ?? ''));

View File

@@ -355,6 +355,12 @@
color: rgba(36, 52, 67, 0.72);
}
.baseball-ticket-bay-app__scope-note,
.baseball-ticket-bay-app__log-client {
font-size: 11px;
color: rgba(36, 52, 67, 0.64);
}
.baseball-ticket-bay-app__item-schedule {
display: inline-flex;
align-items: center;
@@ -403,6 +409,10 @@
opacity: 0.72;
}
.baseball-ticket-bay-app__item--readonly {
border-style: dashed;
}
.baseball-ticket-bay-app__item-top {
justify-content: space-between;
align-items: flex-start;
@@ -414,6 +424,12 @@
min-width: 0;
}
.baseball-ticket-bay-app__log-heading {
display: grid;
gap: 2px;
min-width: 0;
}
.baseball-ticket-bay-app__item-top strong,
.baseball-ticket-bay-app__list-header strong {
font-size: 14px;
@@ -455,6 +471,8 @@
.baseball-ticket-bay-app__success-board {
min-height: 0;
display: grid;
gap: 10px;
}
.baseball-ticket-bay-app__success-layout {
@@ -463,6 +481,38 @@
align-items: start;
}
.baseball-ticket-bay-app__success-selection-toolbar,
.baseball-ticket-bay-app__success-selection-summary,
.baseball-ticket-bay-app__success-selection-toggle,
.baseball-ticket-bay-app__success-item {
display: flex;
align-items: center;
gap: 10px;
}
.baseball-ticket-bay-app__success-selection-toolbar {
justify-content: space-between;
flex-wrap: wrap;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(81, 107, 136, 0.12);
background: rgba(247, 250, 254, 0.88);
}
.baseball-ticket-bay-app__success-selection-toggle {
font-size: 12px;
font-weight: 700;
color: #243443;
}
.baseball-ticket-bay-app__success-selection-summary {
margin-left: auto;
flex-wrap: wrap;
justify-content: flex-end;
font-size: 12px;
color: rgba(36, 52, 67, 0.72);
}
.baseball-ticket-bay-app__success-list {
min-height: 0;
overflow: auto;
@@ -479,12 +529,17 @@
}
.baseball-ticket-bay-app__success-item {
display: grid;
gap: 8px;
width: 100%;
padding: 12px;
text-align: left;
color: #243443;
align-items: flex-start;
}
.baseball-ticket-bay-app__success-item-top-tags {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.baseball-ticket-bay-app__success-item.is-active {
@@ -493,6 +548,22 @@
box-shadow: inset 0 0 0 1px rgba(31, 103, 219, 0.12);
}
.baseball-ticket-bay-app__success-item-selection {
padding-top: 2px;
}
.baseball-ticket-bay-app__success-item-button {
display: grid;
gap: 8px;
flex: 1 1 auto;
width: 100%;
padding: 0;
border: 0;
background: transparent;
text-align: left;
color: #243443;
}
.baseball-ticket-bay-app__success-item-top,
.baseball-ticket-bay-app__success-detail-header {
display: flex;
@@ -501,6 +572,11 @@
gap: 8px;
}
.baseball-ticket-bay-app__success-detail-copy {
min-width: 0;
flex: 1 1 auto;
}
.baseball-ticket-bay-app__success-item-top strong,
.baseball-ticket-bay-app__success-detail-header strong {
font-size: 14px;
@@ -530,6 +606,10 @@
min-width: 0;
}
.baseball-ticket-bay-app__success-detail-actions {
flex: 0 0 auto;
}
.baseball-ticket-bay-app__success-detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -930,6 +1010,16 @@
grid-template-columns: 1fr;
}
.baseball-ticket-bay-app__success-selection-toolbar,
.baseball-ticket-bay-app__success-selection-summary {
align-items: stretch;
flex-direction: column;
}
.baseball-ticket-bay-app__success-selection-summary {
margin-left: 0;
}
.baseball-ticket-bay-app__success-layout:not(.is-detail-open) .baseball-ticket-bay-app__success-detail {
display: none;
}
@@ -952,6 +1042,15 @@
align-items: stretch;
}
.baseball-ticket-bay-app__success-detail-actions {
justify-content: space-between;
align-items: center;
}
.baseball-ticket-bay-app__success-detail-actions .ant-btn {
margin-left: auto;
}
.baseball-ticket-bay-app__success-screen-actions {
flex-direction: row;
align-items: center;

View File

@@ -14,7 +14,7 @@ import {
SendOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { Button, Drawer, Input, InputNumber, Select, Tag, message } from 'antd';
import { Button, Checkbox, Drawer, Input, InputNumber, Select, Tag, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import {
fetchWebPushConfig,
@@ -31,6 +31,7 @@ import {
serializePushSubscription,
syncExistingWebPushSubscriptionRegistration,
} from '../../../../app/main/webPushRegistration';
import { useTokenAccess } from '../../../../app/main/tokenAccess';
import {
createBaseballTicketBayAlert,
deleteBaseballTicketBayAlert,
@@ -38,6 +39,7 @@ import {
fetchBaseballTicketBayAlerts,
fetchBaseballTicketBayLogs,
runBaseballTicketBayAlert as runBaseballTicketBayAlertRequest,
setBaseballTicketBayShareTokenOverride,
updateBaseballTicketBayAlert,
type BaseballTicketBayAlertItem as TicketAlertItem,
type BaseballTicketBayAlertLogItem as AlertLogItem,
@@ -49,6 +51,7 @@ import './BaseballTicketBayPlayAppView.css';
type BaseballTicketBayPlayAppViewProps = {
onBack: () => void;
launchContext?: 'direct' | 'embedded';
shareToken?: string | null;
};
type DraftAlert = {
@@ -73,6 +76,9 @@ type AlertLogAction = 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
type SuccessLogRow = {
id: string;
sourceLogId: string;
clientId: string;
ownerType: 'client' | 'shared-token';
ownerId: string;
alertTitle: string;
ticketTitle: string;
eventDateTime: string;
@@ -108,6 +114,8 @@ type PushTestDiagnostic = {
rows: PushRegistrationStatusRow[];
};
type BaseballTicketBayAccessScope = 'all' | 'client' | 'shared-token';
const TEAM_OPTIONS = ['전체', 'LG', '두산', 'SSG', '키움', 'KT', 'KIA', 'NC', '롯데', '삼성', '한화'].map((value) => ({
label: value,
value,
@@ -387,6 +395,71 @@ function resolveLogStatusLabel(status: AlertLogItem['status']) {
return '기록';
}
function waitForPushServiceWorkerStateChange(
worker: ServiceWorker,
timeoutMs: number,
) {
return new Promise<void>((resolve) => {
let completed = false;
const finish = () => {
if (completed) {
return;
}
completed = true;
window.clearTimeout(timeoutId);
worker.removeEventListener('statechange', onStateChange);
resolve();
};
const onStateChange = () => {
if (worker.state === 'activated' || worker.state === 'installed') {
finish();
}
};
const timeoutId = window.setTimeout(finish, timeoutMs);
worker.addEventListener('statechange', onStateChange);
onStateChange();
});
}
async function resolvePushServiceWorkerRegistration() {
if (typeof window === 'undefined' || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return null;
}
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
const registrationOptions = import.meta.env.DEV ? { scope: '/', type: 'module' as const } : { scope: '/' };
const registration =
(await navigator.serviceWorker.getRegistration()) ??
(await navigator.serviceWorker.register(serviceWorkerUrl, registrationOptions));
if (registration.active || registration.waiting) {
return registration;
}
if (registration.installing) {
await waitForPushServiceWorkerStateChange(registration.installing, 30_000);
}
if (registration.active || registration.waiting) {
return registration;
}
try {
return await Promise.race([
navigator.serviceWorker.ready,
new Promise<null>((resolve) => {
window.setTimeout(() => resolve(null), 30_000);
}),
]);
} catch {
return registration;
}
}
async function ensurePushRegistration() {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
throw new Error('브라우저 환경에서만 사용할 수 있습니다.');
@@ -408,11 +481,11 @@ async function ensurePushRegistration() {
throw new Error('서버 Web Push 설정이 비어 있습니다.');
}
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
const registration =
(await navigator.serviceWorker.getRegistration()) ??
(await navigator.serviceWorker.register(serviceWorkerUrl, import.meta.env.DEV ? { scope: '/', type: 'module' } : { scope: '/' })) ??
(await navigator.serviceWorker.ready);
const registration = await resolvePushServiceWorkerRegistration();
if (!registration) {
throw new Error('서비스 워커를 준비하지 못했습니다.');
}
await ensureWebPushSubscriptionRegistered(registration, {
deviceId: getSavedNotificationDeviceId(),
@@ -428,11 +501,7 @@ async function getCurrentPushRegistration() {
return null;
}
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
const registration =
(await navigator.serviceWorker.getRegistration()) ??
(await navigator.serviceWorker.register(serviceWorkerUrl, import.meta.env.DEV ? { scope: '/', type: 'module' } : { scope: '/' })) ??
(await navigator.serviceWorker.ready);
const registration = await resolvePushServiceWorkerRegistration();
if (!registration) {
return null;
@@ -500,7 +569,7 @@ function buildPushStatusRows(args: {
key: 'deviceId',
label: '기기 ID',
value: args.deviceId || '-',
detail: args.clientId ? `clientId ${args.clientId}` : undefined,
detail: args.clientId ? `기기 식별값 ${args.clientId}` : undefined,
},
{
key: 'endpoint',
@@ -554,12 +623,66 @@ function buildAlertSummary(item: DraftAlert | TicketAlertItem) {
.join(' · ');
}
export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct' }: BaseballTicketBayPlayAppViewProps) {
function formatClientIdLabel(value: string) {
const normalized = value.trim();
if (!normalized) {
return '-';
}
if (normalized.length <= 16) {
return normalized;
}
return `${normalized.slice(0, 8)}...${normalized.slice(-4)}`;
}
function formatScopeIdentifierLabel(value: string | null | undefined) {
return formatClientIdLabel(value?.trim() ?? '');
}
function formatOwnershipLabel(ownerType: 'client' | 'shared-token', ownerId: string | null | undefined, clientId: string) {
if (ownerType === 'shared-token') {
return `토큰 ${formatScopeIdentifierLabel(ownerId)}`;
}
return `기기 ${formatClientIdLabel(clientId)}`;
}
function formatAccessScopeLabel(scope: BaseballTicketBayAccessScope) {
if (scope === 'all') {
return '전체 보기';
}
if (scope === 'shared-token') {
return '공유토큰 기준';
}
return '내 기기 기준';
}
function normalizeShareToken(value: string | null | undefined) {
return value?.trim() ?? '';
}
function readShareTokenFromUrl() {
if (typeof window === 'undefined') {
return '';
}
return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
}
export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct', shareToken }: BaseballTicketBayPlayAppViewProps) {
const [messageApi, contextHolder] = message.useMessage();
const { hasAccess: hasGlobalAccess } = useTokenAccess();
const [clientId, setClientId] = useState('');
const [draft, setDraft] = useState<DraftAlert>(() => createInitialDraft());
const [alerts, setAlerts] = useState<TicketAlertItem[]>([]);
const [logs, setLogs] = useState<AlertLogItem[]>([]);
const [hasAllClientScope, setHasAllClientScope] = useState(false);
const [accessScope, setAccessScope] = useState<BaseballTicketBayAccessScope>('client');
const [scopeOwnerId, setScopeOwnerId] = useState<string | null>(null);
const [isPushPending, setIsPushPending] = useState(false);
const [pushStatusLoading, setPushStatusLoading] = useState(false);
const [pushStatusRows, setPushStatusRows] = useState<PushRegistrationStatusRow[]>([]);
@@ -572,8 +695,18 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
const [isLogDrawerOpen, setIsLogDrawerOpen] = useState(false);
const [selectedLogAlertId, setSelectedLogAlertId] = useState<string | null>(null);
const [selectedSuccessLogId, setSelectedSuccessLogId] = useState<string | null>(null);
const [selectedSuccessRowIds, setSelectedSuccessRowIds] = useState<string[]>([]);
const [editingAlertId, setEditingAlertId] = useState<string | null>(null);
const isEmbeddedLaunch = launchContext === 'embedded';
const effectiveShareToken = normalizeShareToken(shareToken) || readShareTokenFromUrl();
useEffect(() => {
setBaseballTicketBayShareTokenOverride(effectiveShareToken);
return () => {
setBaseballTicketBayShareTokenOverride('');
};
}, [effectiveShareToken]);
useEffect(() => {
const resolvedClientId = getOrCreateClientId().trim();
@@ -582,8 +715,11 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
void (async () => {
try {
const [nextAlerts, nextLogs] = await Promise.all([fetchBaseballTicketBayAlerts(), fetchBaseballTicketBayLogs()]);
setAlerts(nextAlerts);
setLogs(nextLogs);
setAlerts(nextAlerts.items);
setLogs(nextLogs.items);
setHasAllClientScope(nextAlerts.includeAllClients || nextLogs.includeAllClients);
setAccessScope(nextAlerts.accessScope === nextLogs.accessScope ? nextAlerts.accessScope : nextAlerts.accessScope);
setScopeOwnerId(nextAlerts.scopeOwnerId ?? nextLogs.scopeOwnerId ?? null);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '저장된 알림을 불러오지 못했습니다.');
}
@@ -645,14 +781,6 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
}
};
useEffect(() => {
if (!clientId) {
return;
}
void refreshPushStatus({ syncExistingRegistration: true });
}, [clientId]);
useEffect(() => {
if (!isSettingsOpen || !clientId) {
return;
@@ -694,6 +822,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
return item.payload!.results.map((result) => ({
id: `${item.id}:${result.displayNumber}`,
sourceLogId: item.id,
clientId: item.clientId,
ownerType: item.ownerType,
ownerId: item.ownerId,
alertTitle: alert.title,
ticketTitle: result.title,
eventDateTime: result.eventDateTime,
@@ -724,6 +855,38 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
() => successRows.find((item) => item.id === selectedSuccessLogId) ?? successRows[0] ?? null,
[selectedSuccessLogId, successRows],
);
const selectedSuccessRowIdSet = useMemo(() => new Set(selectedSuccessRowIds), [selectedSuccessRowIds]);
const selectedSuccessRows = useMemo(
() => successRows.filter((item) => selectedSuccessRowIdSet.has(item.id)),
[selectedSuccessRowIdSet, successRows],
);
const selectedSuccessSourceLogIds = useMemo(
() => Array.from(new Set(selectedSuccessRows.map((item) => item.sourceLogId))),
[selectedSuccessRows],
);
const selectableSuccessRows = useMemo(
() => (accessScope === 'shared-token' ? successRows : successRows.filter((item) => item.clientId === clientId)),
[accessScope, clientId, successRows],
);
const isAllSuccessRowsSelected = selectableSuccessRows.length > 0 && selectedSuccessRowIds.length === selectableSuccessRows.length;
const isSomeSuccessRowsSelected = selectedSuccessRowIds.length > 0 && selectedSuccessRowIds.length < selectableSuccessRows.length;
const isViewingAllClients = !effectiveShareToken && (hasGlobalAccess || hasAllClientScope);
const isSharedTokenScope = accessScope === 'shared-token';
const scopeSummaryLabel = isSharedTokenScope ? '공유 토큰' : '접속 기기';
const scopeSummaryValue = isSharedTokenScope
? formatScopeIdentifierLabel(scopeOwnerId || effectiveShareToken || clientId)
: (clientId ? clientId.slice(0, 12) : '-');
const canManageByClientId = (targetClientId: string) => {
if (accessScope === 'shared-token') {
return true;
}
if (accessScope === 'all') {
return false;
}
return targetClientId === clientId;
};
useEffect(() => {
if (!successRows.length) {
@@ -738,6 +901,10 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
}
}, [selectedSuccessLogId, successRows]);
useEffect(() => {
setSelectedSuccessRowIds((previous) => previous.filter((rowId) => successRows.some((item) => item.id === rowId)));
}, [successRows]);
const isSuccessPanel = activePanel === 'success-list' || activePanel === 'success-detail';
const openSuccessList = () => {
@@ -814,6 +981,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
setLogs((previous) => [
{
id: createId('log'),
clientId,
createdAt: new Date().toISOString(),
...item,
},
@@ -823,8 +991,11 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
const reloadServerData = async () => {
const [nextAlerts, nextLogs] = await Promise.all([fetchBaseballTicketBayAlerts(), fetchBaseballTicketBayLogs()]);
setAlerts(nextAlerts);
setLogs(nextLogs);
setAlerts(nextAlerts.items);
setLogs(nextLogs.items);
setHasAllClientScope(nextAlerts.includeAllClients || nextLogs.includeAllClients);
setAccessScope(nextAlerts.accessScope === nextLogs.accessScope ? nextAlerts.accessScope : nextAlerts.accessScope);
setScopeOwnerId(nextAlerts.scopeOwnerId ?? nextLogs.scopeOwnerId ?? null);
};
const runAlertNow = async (
@@ -893,6 +1064,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
const nextItem: TicketAlertItem = {
id: editingAlertId ?? createId('alert'),
clientId,
ownerType: accessScope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessScope === 'shared-token' ? scopeOwnerId ?? clientId : clientId,
title: draft.title.trim(),
eventDate: draft.eventDate,
team: draft.team,
@@ -1036,6 +1210,67 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
}
};
const handleToggleSuccessRowSelection = (rowId: string) => {
const target = successRows.find((item) => item.id === rowId);
if (!target || !canManageByClientId(target.clientId)) {
return;
}
setSelectedSuccessRowIds((previous) => (previous.includes(rowId) ? previous.filter((item) => item !== rowId) : [...previous, rowId]));
};
const handleToggleAllSuccessRows = () => {
setSelectedSuccessRowIds((previous) => (previous.length === selectableSuccessRows.length ? [] : selectableSuccessRows.map((item) => item.id)));
};
const handleDeleteSelectedSuccessLogs = async () => {
if (!selectedSuccessSourceLogIds.length) {
messageApi.error('삭제할 성공 항목을 먼저 선택해 주세요.');
return;
}
const confirmMessage =
selectedSuccessSourceLogIds.length === 1
? '선택한 성공 항목을 삭제할까요? 같은 실행에서 저장된 성공 항목이 함께 사라집니다.'
: `선택한 성공 항목 ${selectedSuccessRowIds.length}건을 삭제할까요? ${selectedSuccessSourceLogIds.length}개 실행 로그가 함께 삭제됩니다.`;
if (typeof window !== 'undefined' && !window.confirm(confirmMessage)) {
return;
}
const results = await Promise.allSettled(selectedSuccessSourceLogIds.map((logId) => deleteBaseballTicketBayLog(logId)));
const successCount = results.filter((result) => result.status === 'fulfilled').length;
const failureResults = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
try {
await reloadServerData();
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '성공 로그 목록을 다시 불러오지 못했습니다.');
return;
}
setSelectedSuccessRowIds([]);
if (!failureResults.length) {
messageApi.success(`선택한 성공 항목 ${selectedSuccessRowIds.length}건을 삭제했습니다.`);
return;
}
if (successCount > 0) {
messageApi.warning(
`선택 삭제 중 ${successCount}개 실행 로그만 삭제했습니다. ${
failureResults[0].reason instanceof Error ? failureResults[0].reason.message : '일부 성공 로그 삭제에 실패했습니다.'
}`,
);
return;
}
messageApi.error(
failureResults[0].reason instanceof Error ? failureResults[0].reason.message : '선택한 성공 로그 삭제에 실패했습니다.',
);
};
const handleReload = async () => {
try {
await reloadServerData();
@@ -1057,10 +1292,10 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
alertTitle: '공통',
action: 'push',
status: 'success',
message: '현재 클라이언트의 Web Push 등록을 완료했습니다.',
message: '현재 기기의 Web Push 등록을 완료했습니다.',
detail: clientId || '-',
});
messageApi.success('현재 클라이언트의 Web Push 등록을 완료했습니다.');
messageApi.success('현재 기기의 Web Push 등록을 완료했습니다.');
} catch (error) {
appendLog({
alertId: null,
@@ -1078,7 +1313,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
const handleSendTestPush = async () => {
if (!clientId) {
messageApi.error('clientId를 확인하지 못했습니다.');
messageApi.error('기기 식별값을 확인하지 못했습니다.');
return;
}
@@ -1263,7 +1498,12 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
<div>
<strong>-</strong>
<div className="baseball-ticket-bay-app__meta">
<span>clientId {clientId ? clientId.slice(0, 12) : '-'}</span>
<span>{scopeSummaryLabel} {scopeSummaryValue}</span>
{isViewingAllClients || isSharedTokenScope ? (
<Tag color="gold" bordered={false}>
{formatAccessScopeLabel(accessScope)}
</Tag>
) : null}
<Tag color="blue" bordered={false}>
{activeCount}
</Tag>
@@ -1336,35 +1576,70 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
</div>
<div className="baseball-ticket-bay-app__success-screen-body">
{successRows.length ? (
<div className={`baseball-ticket-bay-app__success-layout${activePanel === 'success-detail' ? ' is-detail-open' : ''}`}>
<div className="baseball-ticket-bay-app__success-list" role="list" aria-label="성공한 실행 목록">
{successRows.map((item) => (
<button
key={item.id}
type="button"
className={`baseball-ticket-bay-app__success-item${selectedSuccessRow?.id === item.id ? ' is-active' : ''}`}
onClick={() => openSuccessDetail(item.id)}
>
<div className="baseball-ticket-bay-app__success-item-top">
<strong>{item.ticketTitle}</strong>
<Tag bordered={false} color="green">
</Tag>
</div>
<div className="baseball-ticket-bay-app__success-item-meta">
<span>{formatDateTimeLabel(item.eventDateTime)}</span>
<span>{item.teamName}</span>
<span>{item.seatCount}</span>
</div>
<div className="baseball-ticket-bay-app__success-item-summary">{item.summary}</div>
<div className="baseball-ticket-bay-app__success-item-time">{formatDateTimeLabel(item.createdAt)}</div>
</button>
))}
</div>
{selectedSuccessRow ? (
<article className="baseball-ticket-bay-app__success-detail" aria-label="성공 티켓 상세">
<div className="baseball-ticket-bay-app__success-board">
{activePanel !== 'success-detail' ? (
<div className="baseball-ticket-bay-app__success-selection-toolbar">
<label className="baseball-ticket-bay-app__success-selection-toggle">
<Checkbox checked={isAllSuccessRowsSelected} indeterminate={isSomeSuccessRowsSelected} onChange={handleToggleAllSuccessRows} />
<span> </span>
</label>
<div className="baseball-ticket-bay-app__success-selection-summary">
<span>
{selectedSuccessRowIds.length}
{selectedSuccessSourceLogIds.length ? ` · ${selectedSuccessSourceLogIds.length}개 실행 삭제` : ''}
</span>
<Button danger disabled={!selectedSuccessSourceLogIds.length} icon={<DeleteOutlined />} onClick={() => void handleDeleteSelectedSuccessLogs()}>
</Button>
</div>
</div>
) : null}
<div className={`baseball-ticket-bay-app__success-layout${activePanel === 'success-detail' ? ' is-detail-open' : ''}`}>
<div className="baseball-ticket-bay-app__success-list" role="list" aria-label="성공한 실행 목록">
{successRows.map((item) => (
<article
key={item.id}
className={`baseball-ticket-bay-app__success-item${selectedSuccessRow?.id === item.id ? ' is-active' : ''}`}
>
<div className="baseball-ticket-bay-app__success-item-selection">
<Checkbox
checked={selectedSuccessRowIdSet.has(item.id)}
disabled={!canManageByClientId(item.clientId)}
aria-label={`${item.ticketTitle} 성공 항목 선택`}
onChange={() => handleToggleSuccessRowSelection(item.id)}
/>
</div>
<button
type="button"
className="baseball-ticket-bay-app__success-item-button"
onClick={() => openSuccessDetail(item.id)}
>
<div className="baseball-ticket-bay-app__success-item-top">
<strong>{item.ticketTitle}</strong>
<div className="baseball-ticket-bay-app__success-item-top-tags">
{isViewingAllClients || isSharedTokenScope ? (
<Tag bordered={false}>{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}</Tag>
) : null}
<Tag bordered={false} color="green">
</Tag>
</div>
</div>
<div className="baseball-ticket-bay-app__success-item-meta">
<span>{formatDateTimeLabel(item.eventDateTime)}</span>
<span>{item.teamName}</span>
<span>{item.seatCount}</span>
</div>
<div className="baseball-ticket-bay-app__success-item-summary">{item.summary}</div>
<div className="baseball-ticket-bay-app__success-item-time">{formatDateTimeLabel(item.createdAt)}</div>
</button>
</article>
))}
</div>
{selectedSuccessRow ? (
<article className="baseball-ticket-bay-app__success-detail" aria-label="성공 티켓 상세">
<div className="baseball-ticket-bay-app__success-detail-header">
<div>
<div className="baseball-ticket-bay-app__success-detail-copy">
<strong>{selectedSuccessRow.ticketTitle}</strong>
<div className="baseball-ticket-bay-app__success-detail-subtitle">
{formatDateTimeLabel(selectedSuccessRow.eventDateTime)} · {selectedSuccessRow.teamName}
@@ -1374,12 +1649,18 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
<Tag bordered={false} color="green">
</Tag>
{isViewingAllClients || isSharedTokenScope ? (
<Tag bordered={false}>
{formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId)}
</Tag>
) : null}
<Button
type="text"
danger
icon={<DeleteOutlined />}
aria-label="성공 로그 삭제"
title="성공 로그 삭제"
disabled={!canManageByClientId(selectedSuccessRow.clientId)}
onClick={() => void handleDeleteSuccessLog(selectedSuccessRow.sourceLogId)}
>
@@ -1479,8 +1760,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
<strong> .</strong>
</div>
)}
</article>
) : null}
</article>
) : null}
</div>
</div>
) : (
<div className="baseball-ticket-bay-app__empty"> .</div>
@@ -1759,15 +2041,31 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
<span>{alerts.length}</span>
</div>
</div>
{isViewingAllClients || isSharedTokenScope ? (
<div className="baseball-ticket-bay-app__scope-note">
{isSharedTokenScope ? '현재 공유 토큰 기준으로 목록을 표시합니다.' : '등록 토큰으로 전체 기기 목록을 보고 있습니다. 수정과 삭제는 현재 기기 항목만 가능합니다.'}
</div>
) : null}
<div className="baseball-ticket-bay-app__items">
{alerts.length ? (
alerts.map((item) => (
<article key={item.id} className={`baseball-ticket-bay-app__item${item.active ? '' : ' baseball-ticket-bay-app__item--paused'}`}>
alerts.map((item) => {
const isOwnedByCurrentClient = canManageByClientId(item.clientId);
return (
<article
key={item.id}
className={`baseball-ticket-bay-app__item${item.active ? '' : ' baseball-ticket-bay-app__item--paused'}${!isOwnedByCurrentClient ? ' baseball-ticket-bay-app__item--readonly' : ''}`}
>
<div className="baseball-ticket-bay-app__item-top">
<div className="baseball-ticket-bay-app__item-heading">
<strong>{item.title}</strong>
<div className="baseball-ticket-bay-app__item-date">{formatDateLabel(item.eventDate)}</div>
<div className="baseball-ticket-bay-app__item-tags">
{isViewingAllClients || isSharedTokenScope ? (
<Tag bordered={false} color={isOwnedByCurrentClient ? 'blue' : 'default'}>
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
</Tag>
) : null}
<Tag bordered={false} color={item.active ? 'green' : 'default'}>
{item.active ? '실행중' : '중지'}
</Tag>
@@ -1788,12 +2086,13 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
</div>
</div>
<div className="baseball-ticket-bay-app__item-actions">
<Button type="text" icon={<EditOutlined />} onClick={() => handleEditAlert(item.id)}>
<Button type="text" icon={<EditOutlined />} disabled={!isOwnedByCurrentClient} onClick={() => handleEditAlert(item.id)}>
</Button>
<Button
type="text"
icon={<SendOutlined />}
disabled={!isOwnedByCurrentClient}
loading={isImmediateRunPending}
onClick={() => void runAlertNow(item, 'run', { openLogsOnComplete: true })}
/>
@@ -1808,9 +2107,10 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
<Button
type="text"
icon={item.active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
disabled={!isOwnedByCurrentClient}
onClick={() => handleToggleAlert(item.id)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDeleteAlert(item.id)} />
<Button type="text" danger icon={<DeleteOutlined />} disabled={!isOwnedByCurrentClient} onClick={() => handleDeleteAlert(item.id)} />
</div>
</div>
<div className="baseball-ticket-bay-app__item-summary">{buildAlertSummary(item)}</div>
@@ -1824,7 +2124,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
</div>
) : null}
</article>
))
)})
) : (
<div className="baseball-ticket-bay-app__empty"> .</div>
)}
@@ -1864,7 +2164,14 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
visibleLogs.map((item) => (
<article key={item.id} className="baseball-ticket-bay-app__log-item">
<div className="baseball-ticket-bay-app__log-top">
<strong>{item.alertTitle}</strong>
<div className="baseball-ticket-bay-app__log-heading">
<strong>{item.alertTitle}</strong>
{isViewingAllClients || isSharedTokenScope ? (
<span className="baseball-ticket-bay-app__log-client">
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
</span>
) : null}
</div>
<div className="baseball-ticket-bay-app__log-top-actions">
<Tag bordered={false} color={resolveLogTagColor(item.status)}>
{resolveLogStatusLabel(item.status)}
@@ -1877,6 +2184,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
icon={<DeleteOutlined />}
aria-label="성공 로그 삭제"
title="성공 로그 삭제"
disabled={!canManageByClientId(item.clientId)}
onClick={() => void handleDeleteSuccessLog(item.id)}
/>
) : null}
@@ -1904,8 +2212,8 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct'
>
<div className="baseball-ticket-bay-app__settings">
<div className="baseball-ticket-bay-app__settings-block">
<span> </span>
<strong>{clientId || '-'}</strong>
<span>{isSharedTokenScope ? '공유 토큰' : '접속 기기'}</span>
<strong>{isSharedTokenScope ? formatScopeIdentifierLabel(scopeOwnerId || effectiveShareToken || clientId) : (clientId || '-')}</strong>
</div>
<div className="baseball-ticket-bay-app__settings-block">
<div className="baseball-ticket-bay-app__settings-block-header">

View File

@@ -3,6 +3,7 @@ import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
const WORK_SERVER_TIMEOUT_MS = 15_000;
const BASEBALL_TICKET_BAY_RUN_TIMEOUT_MS = 180_000;
let baseballTicketBayShareTokenOverride = '';
export type BaseballTicketBayTimeWindow = {
id: string;
@@ -12,6 +13,9 @@ export type BaseballTicketBayTimeWindow = {
export type BaseballTicketBayAlertItem = {
id: string;
clientId: string;
ownerType: 'client' | 'shared-token';
ownerId: string;
title: string;
eventDate: string;
team: string;
@@ -85,6 +89,9 @@ export type BaseballTicketBayRunPayload = {
export type BaseballTicketBayAlertLogItem = {
id: string;
clientId: string;
ownerType: 'client' | 'shared-token';
ownerId: string;
alertId: string | null;
alertTitle: string;
action: 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
@@ -97,19 +104,39 @@ export type BaseballTicketBayAlertLogItem = {
type BaseballTicketBayAlertMutation = Omit<
BaseballTicketBayAlertItem,
'id' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
'id' | 'clientId' | 'ownerType' | 'ownerId' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
>;
type BaseballTicketBayAlertsResponse = {
ok: boolean;
includeAllClients?: boolean;
accessScope?: 'all' | 'client' | 'shared-token';
scopeOwnerId?: string | null;
items: BaseballTicketBayAlertItem[];
};
type BaseballTicketBayLogsResponse = {
ok: boolean;
includeAllClients?: boolean;
accessScope?: 'all' | 'client' | 'shared-token';
scopeOwnerId?: string | null;
items: BaseballTicketBayAlertLogItem[];
};
export type BaseballTicketBayAlertsResult = {
items: BaseballTicketBayAlertItem[];
includeAllClients: boolean;
accessScope: 'all' | 'client' | 'shared-token';
scopeOwnerId: string | null;
};
export type BaseballTicketBayLogsResult = {
items: BaseballTicketBayAlertLogItem[];
includeAllClients: boolean;
accessScope: 'all' | 'client' | 'shared-token';
scopeOwnerId: string | null;
};
type BaseballTicketBayAlertResponse = {
ok: boolean;
item: BaseballTicketBayAlertItem;
@@ -133,16 +160,38 @@ function resolveApiBaseUrl() {
const API_BASE_URL = resolveApiBaseUrl();
function normalizeShareToken(value: string | null | undefined) {
return value?.trim() ?? '';
}
function readShareTokenFromUrl() {
if (typeof window === 'undefined') {
return '';
}
return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
}
function resolveBaseballTicketBayShareToken() {
return normalizeShareToken(baseballTicketBayShareTokenOverride) || readShareTokenFromUrl();
}
export function setBaseballTicketBayShareTokenOverride(shareToken: string | null | undefined) {
baseballTicketBayShareTokenOverride = normalizeShareToken(shareToken);
}
function buildHeaders(headersInit?: HeadersInit, hasJsonBody = false) {
const headers = appendClientIdHeader(headersInit);
const token = getRegisteredAccessToken();
const registeredToken = getRegisteredAccessToken();
const shareToken = resolveBaseballTicketBayShareToken();
const accessToken = shareToken || registeredToken;
if (hasJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
if (accessToken && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', accessToken);
}
return headers;
@@ -194,7 +243,12 @@ async function request<T>(path: string, init?: (RequestInit & { timeoutMs?: numb
export async function fetchBaseballTicketBayAlerts() {
const response = await request<BaseballTicketBayAlertsResponse>('/baseball-ticket-bay/alerts');
return response.items;
return {
items: response.items,
includeAllClients: response.includeAllClients === true,
accessScope: response.accessScope === 'all' || response.accessScope === 'shared-token' ? response.accessScope : 'client',
scopeOwnerId: typeof response.scopeOwnerId === 'string' && response.scopeOwnerId.trim() ? response.scopeOwnerId.trim() : null,
} satisfies BaseballTicketBayAlertsResult;
}
export async function fetchBaseballTicketBayLogs(alertId?: string | null) {
@@ -206,7 +260,12 @@ export async function fetchBaseballTicketBayLogs(alertId?: string | null) {
const path = params.size > 0 ? `/baseball-ticket-bay/logs?${params.toString()}` : '/baseball-ticket-bay/logs';
const response = await request<BaseballTicketBayLogsResponse>(path);
return response.items;
return {
items: response.items,
includeAllClients: response.includeAllClients === true,
accessScope: response.accessScope === 'all' || response.accessScope === 'shared-token' ? response.accessScope : 'client',
scopeOwnerId: typeof response.scopeOwnerId === 'string' && response.scopeOwnerId.trim() ? response.scopeOwnerId.trim() : null,
} satisfies BaseballTicketBayLogsResult;
}
export async function deleteBaseballTicketBayLog(logId: string) {

View File

@@ -1048,6 +1048,7 @@ async function createEReaderManifestObjectUrl(options?: {
startUrl.searchParams.set('shareToken', shareToken);
}
manifest.scope = startUrl.pathname;
manifest.start_url = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;
return window.URL.createObjectURL(

View File

@@ -207,8 +207,13 @@
display: grid;
min-height: 100%;
height: 100%;
min-width: 0;
place-items: center;
padding: 24px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.photoprism-app__login-card {
@@ -221,6 +226,18 @@
backdrop-filter: blur(16px);
}
@media (max-height: 760px) {
.photoprism-app__login-shell {
align-content: start;
justify-items: stretch;
padding-block: 16px 24px;
}
.photoprism-app__login-card {
margin-block: auto;
}
}
.photoprism-app__login-card .ant-form-item {
margin-bottom: 12px;
}