chore: test deploy snapshot
This commit is contained in:
@@ -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] ?? ''));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user