feat: refresh shared chat and server workflows

This commit is contained in:
2026-05-26 12:26:33 +09:00
parent 51e0099bea
commit c1d0f4c1db
82 changed files with 18604 additions and 12461 deletions

View File

@@ -79,6 +79,12 @@
rgba(255, 255, 255, 0.06);
}
.apps-library__card--baseball-ticket-bay {
background:
linear-gradient(180deg, rgba(255, 128, 82, 0.3), rgba(78, 132, 255, 0.14)),
rgba(255, 255, 255, 0.06);
}
.apps-library__card--beat {
background:
linear-gradient(180deg, rgba(127, 114, 255, 0.18), rgba(255, 255, 255, 0.04)),

View File

@@ -2,6 +2,7 @@ import { Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import './AppsLibraryView.css';
import { BaseballTicketBayPlayAppView } from '../baseball-ticket-bay/BaseballTicketBayPlayAppView';
import { EReaderAppView } from '../e-reader/EReaderAppView';
import { PhotoPrismAppView } from '../photoprism/PhotoPrismAppView';
import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView';
@@ -72,6 +73,10 @@ export function AppsLibraryView() {
return <PhotoPrismAppView onBack={closeApp} launchContext={launchContext} />;
}
if (activeAppEntry?.id === 'baseball-ticket-bay') {
return <BaseballTicketBayPlayAppView onBack={closeApp} launchContext={launchContext} />;
}
if (activeAppEntry?.id === 'e-reader') {
return <EReaderAppView onBack={closeApp} launchContext={launchContext} />;
}
@@ -110,6 +115,8 @@ export function AppsLibraryView() {
data-testid={
entry.id === 'e-reader'
? 'apps-library-open-e-reader'
: entry.id === 'baseball-ticket-bay'
? 'apps-library-open-baseball-ticket-bay'
: entry.id === 'photoprism'
? 'apps-library-open-photoprism'
: entry.id === 'photo-puzzle'

View File

@@ -1,5 +1,6 @@
import {
AppstoreOutlined,
BellOutlined,
BookOutlined,
FileImageOutlined,
FireOutlined,
@@ -21,12 +22,25 @@ export type PlayAppEntry = {
statusLabel: string;
isReady: boolean;
icon: ReactNode;
usagePriority?: number;
supportedEnvironments?: PlayAppEnvironment[];
searchKeywords?: string[];
searchDescription?: string;
};
export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
{
id: 'baseball-ticket-bay',
name: '야구-티켓베이',
accentClassName: 'apps-library__card--baseball-ticket-bay',
statusLabel: '알림',
isReady: true,
icon: <BellOutlined />,
usagePriority: 100,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['야구', '티켓베이', 'ticketbay', '야구 티켓', '웹푸시', '알림'],
searchDescription: '팀, 구역, 통로, 가격 조건으로 야구 티켓 알림 조건을 저장하고 테스트 푸시를 보냅니다.',
},
{
id: 'e-reader',
name: 'E-Reader',
@@ -34,6 +48,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '읽기',
isReady: true,
icon: <BookOutlined />,
usagePriority: 80,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'],
searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.',
@@ -45,6 +60,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '연결',
isReady: true,
icon: <FileImageOutlined />,
usagePriority: 70,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
@@ -56,6 +72,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '실행',
isReady: true,
icon: <PictureOutlined />,
usagePriority: 60,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
@@ -67,6 +84,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '신규',
isReady: true,
icon: <ThunderboltOutlined />,
usagePriority: 50,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
@@ -78,6 +96,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '실행',
isReady: true,
icon: <FundProjectionScreenOutlined />,
usagePriority: 40,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
import { appendClientIdHeader } from '../../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
const WORK_SERVER_TIMEOUT_MS = 15_000;
const BASEBALL_TICKET_BAY_RUN_TIMEOUT_MS = 180_000;
export type BaseballTicketBayTimeWindow = {
id: string;
start: string;
end: string;
};
export type BaseballTicketBayAlertItem = {
id: string;
title: string;
eventDate: string;
team: string;
zone: string;
aisleSide: string;
seatDirections: string[];
maxPrice: number | null;
seatCount: number;
batchIntervalMinutes: number;
sameProductAlertEnabled: boolean;
sameProductNotifyOnce: boolean;
active: boolean;
timeWindows: BaseballTicketBayTimeWindow[];
createdAt: string;
updatedAt: string;
lastRunAt: string | null;
lastMatchAt: string | null;
};
export type BaseballTicketBayMatchResult = {
productId: number;
displayNumber: string;
saleUrl: string;
title: string;
eventDateTime: string;
categoryName: string;
teamName: string;
area: string;
rowLabel: string;
row: string;
opponentOrFloor: string;
grade: string;
addInfo: string;
seatCount: number | null;
together: boolean;
price: number | null;
totalPrice: number | null;
transactionType: string;
sellerPost: string;
productRemarks: string[];
seatRemarks: string[];
seatMapImageUrl: string | null;
photoUrls: string[];
sellerPhotoCount: number;
createdAt: string;
safeTrade: boolean;
pinTrade: boolean;
deliveryTrade: boolean;
fieldTrade: boolean;
etcTrade: boolean;
};
export type BaseballTicketBayRunPayload = {
keyword: string;
scannedCategoryCount: number;
scannedItemTotalCount?: number;
scannedCategories: Array<{
categoryId: number;
categoryName: string;
pageCount: number;
scannedItemCount: number;
}>;
rejectionSummary?: Array<{
reason: string;
label: string;
count: number;
samples: string[];
}>;
results: BaseballTicketBayMatchResult[];
};
export type BaseballTicketBayAlertLogItem = {
id: string;
alertId: string | null;
alertTitle: string;
action: 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
status: 'info' | 'success' | 'warning' | 'error';
message: string;
detail: string;
createdAt: string;
payload?: BaseballTicketBayRunPayload | null;
};
type BaseballTicketBayAlertMutation = Omit<
BaseballTicketBayAlertItem,
'id' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
>;
type BaseballTicketBayAlertsResponse = {
ok: boolean;
items: BaseballTicketBayAlertItem[];
};
type BaseballTicketBayLogsResponse = {
ok: boolean;
items: BaseballTicketBayAlertLogItem[];
};
type BaseballTicketBayAlertResponse = {
ok: boolean;
item: BaseballTicketBayAlertItem;
};
type BaseballTicketBayRunResponse = {
ok: boolean;
alert: BaseballTicketBayAlertItem;
matches: BaseballTicketBayMatchResult[];
notifiedMatches: BaseballTicketBayMatchResult[];
log: BaseballTicketBayAlertLogItem;
};
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
const API_BASE_URL = resolveApiBaseUrl();
function buildHeaders(headersInit?: HeadersInit, hasJsonBody = false) {
const headers = appendClientIdHeader(headersInit);
const token = getRegisteredAccessToken();
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);
}
return headers;
}
async function request<T>(path: string, init?: (RequestInit & { timeoutMs?: number })) {
const controller = new AbortController();
const timeoutMs =
typeof init?.timeoutMs === 'number' && Number.isFinite(init.timeoutMs)
? Math.max(1_000, init.timeoutMs)
: WORK_SERVER_TIMEOUT_MS;
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
const hasBody = init?.body != null;
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: buildHeaders(init?.headers, hasBody),
signal: controller.signal,
cache: init?.cache ?? 'no-store',
});
if (!response.ok) {
const text = await response.text();
try {
const parsed = JSON.parse(text) as { message?: string };
throw new Error(parsed.message || '야구 티켓베이 요청에 실패했습니다.');
} catch (error) {
if (error instanceof Error && error.message !== text) {
throw error;
}
throw new Error(text || '야구 티켓베이 요청에 실패했습니다.');
}
}
return (await response.json()) as T;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('야구 티켓베이 응답이 지연됩니다.');
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function fetchBaseballTicketBayAlerts() {
const response = await request<BaseballTicketBayAlertsResponse>('/baseball-ticket-bay/alerts');
return response.items;
}
export async function fetchBaseballTicketBayLogs(alertId?: string | null) {
const params = new URLSearchParams();
if (alertId) {
params.set('alertId', alertId);
}
const path = params.size > 0 ? `/baseball-ticket-bay/logs?${params.toString()}` : '/baseball-ticket-bay/logs';
const response = await request<BaseballTicketBayLogsResponse>(path);
return response.items;
}
export async function deleteBaseballTicketBayLog(logId: string) {
await request<{ ok: boolean; item: BaseballTicketBayAlertLogItem | null }>(
`/baseball-ticket-bay/logs/${encodeURIComponent(logId)}`,
{
method: 'DELETE',
},
);
}
export async function createBaseballTicketBayAlert(payload: BaseballTicketBayAlertMutation) {
const response = await request<BaseballTicketBayAlertResponse>('/baseball-ticket-bay/alerts', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.item;
}
export async function updateBaseballTicketBayAlert(alertId: string, payload: Partial<BaseballTicketBayAlertMutation>) {
const response = await request<BaseballTicketBayAlertResponse>(`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
return response.item;
}
export async function deleteBaseballTicketBayAlert(alertId: string) {
await request<{ ok: boolean; item: BaseballTicketBayAlertItem | null }>(
`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}`,
{
method: 'DELETE',
},
);
}
export async function runBaseballTicketBayAlert(alertId: string) {
return request<BaseballTicketBayRunResponse>(`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}/run`, {
method: 'POST',
timeoutMs: BASEBALL_TICKET_BAY_RUN_TIMEOUT_MS,
});
}

View File

@@ -24,6 +24,7 @@ import {
listReaderLibraryArticles,
searchReaderNews,
saveReaderLibraryArticle,
setEReaderShareTokenOverride,
type EReaderLibraryArticle,
type EReaderNewsArticle,
type EReaderNewsSearchParams,
@@ -33,6 +34,7 @@ import './EReaderAppView.css';
type EReaderAppViewProps = {
onBack: () => void;
launchContext?: 'direct' | 'embedded';
shareToken?: string | null;
};
type ReaderTheme = 'mist' | 'ocean' | 'night' | 'sepia' | 'forest' | 'graphite' | 'rose' | 'dawn';
@@ -994,6 +996,18 @@ function getReaderLaunchUrl() {
return new URL(getReaderLaunchPath(), window.location.origin).toString();
}
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 getInstallGuideMessage() {
if (typeof navigator !== 'undefined' && /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
return 'Safari 공유 메뉴에서 홈 화면에 추가를 선택하면 E-Reader 전용 아이콘으로 저장됩니다.';
@@ -1002,7 +1016,10 @@ function getInstallGuideMessage() {
return '브라우저 메뉴의 홈 화면에 추가 또는 앱 설치를 사용하면 E-Reader를 바로 열 수 있습니다.';
}
async function createEReaderManifestObjectUrl(registeredToken: string) {
async function createEReaderManifestObjectUrl(options?: {
registeredToken?: string | null;
shareToken?: string | null;
}) {
if (typeof window === 'undefined') {
return '';
}
@@ -1020,8 +1037,17 @@ async function createEReaderManifestObjectUrl(registeredToken: string) {
typeof manifest.start_url === 'string' && manifest.start_url.trim() ? manifest.start_url : getReaderLaunchPath(),
window.location.origin,
);
const registeredToken = options?.registeredToken?.trim() ?? '';
const shareToken = normalizeShareToken(options?.shareToken);
if (registeredToken) {
startUrl.searchParams.set('registeredAccessToken', registeredToken);
}
if (shareToken) {
startUrl.searchParams.set('shareToken', shareToken);
}
startUrl.searchParams.set('registeredAccessToken', registeredToken);
manifest.start_url = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;
return window.URL.createObjectURL(
@@ -1297,7 +1323,7 @@ function sleep(ms: number) {
});
}
export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppViewProps) {
export function EReaderAppView({ onBack, launchContext = 'direct', shareToken }: EReaderAppViewProps) {
const storedSettings = readStoredSettings();
const initialStoredArticles = readStoredArticles();
const initialReadHistory = readStoredReadHistory();
@@ -1442,6 +1468,7 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
const safeDisplayPageIndex = clampIndex(displayPageIndex, pageCount);
const hiddenPageSlot = visiblePageSlot === 'primary' ? 'secondary' : 'primary';
const isEmbeddedLaunch = launchContext === 'embedded';
const effectiveShareToken = normalizeShareToken(shareToken) || readShareTokenFromUrl();
const hasViewHistory = viewHistory.length > 0;
const canExitToParentApp = isEmbeddedLaunch && installState !== 'standalone';
const isStandaloneHome = installState === 'standalone' && currentView === 'home';
@@ -1610,6 +1637,14 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
};
}, [librarySyncRequestId]);
useEffect(() => {
setEReaderShareTokenOverride(effectiveShareToken);
return () => {
setEReaderShareTokenOverride('');
};
}, [effectiveShareToken]);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
@@ -1621,10 +1656,13 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
const previewRuntimeToken = isPreviewRuntime() ? getRegisteredAccessToken() : '';
const restoreManifest = swapManifestForEReader();
if (previewRuntimeToken) {
if (previewRuntimeToken || effectiveShareToken) {
void (async () => {
try {
const manifestObjectUrl = await createEReaderManifestObjectUrl(previewRuntimeToken);
const manifestObjectUrl = await createEReaderManifestObjectUrl({
registeredToken: previewRuntimeToken,
shareToken: effectiveShareToken,
});
if (isDisposed) {
window.URL.revokeObjectURL(manifestObjectUrl);
@@ -1648,7 +1686,7 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
restoreManifest();
document.body.classList.remove(E_READER_IMMERSIVE_BODY_CLASS);
};
}, []);
}, [effectiveShareToken]);
useEffect(() => {
if (typeof window === 'undefined') {

View File

@@ -92,6 +92,7 @@ const WORK_SERVER_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
let eReaderShareTokenOverride = '';
class EReaderApiError extends Error {
status: number;
@@ -103,6 +104,26 @@ class EReaderApiError extends Error {
}
}
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 resolveReaderShareToken() {
return normalizeShareToken(eReaderShareTokenOverride) || readShareTokenFromUrl();
}
export function setEReaderShareTokenOverride(shareToken: string | null | undefined) {
eReaderShareTokenOverride = normalizeShareToken(shareToken);
}
function parseJsonPayload<T>(text: string, fallbackMessage: string) {
if (!text.trim()) {
throw new EReaderApiError(fallbackMessage, 502);
@@ -206,11 +227,16 @@ export async function extractReaderArticle(url: string) {
function buildReaderHeaders() {
const headers = appendClientIdHeader();
const token = getRegisteredAccessToken();
const shareToken = resolveReaderShareToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
if (shareToken && !headers.has('X-Chat-Share-Token')) {
headers.set('X-Chat-Share-Token', shareToken);
}
return headers;
}