chore: test deploy snapshot
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -14,6 +14,7 @@ import { registerPlanRoutes } from './routes/plan.js';
|
|||||||
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
|
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
|
||||||
import { registerReaderRoutes } from './routes/reader.js';
|
import { registerReaderRoutes } from './routes/reader.js';
|
||||||
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
|
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
|
||||||
|
import { registerPlayAppRoutes } from './routes/play-app.js';
|
||||||
import { registerRuntimeRoutes } from './routes/runtime.js';
|
import { registerRuntimeRoutes } from './routes/runtime.js';
|
||||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||||
import { registerSchemaRoutes } from './routes/schema.js';
|
import { registerSchemaRoutes } from './routes/schema.js';
|
||||||
@@ -92,6 +93,7 @@ export function createApp() {
|
|||||||
app.register(registerNotificationRoutes);
|
app.register(registerNotificationRoutes);
|
||||||
app.register(registerPlanRoutes);
|
app.register(registerPlanRoutes);
|
||||||
app.register(registerPhotoPrismRoutes);
|
app.register(registerPhotoPrismRoutes);
|
||||||
|
app.register(registerPlayAppRoutes);
|
||||||
app.register(registerReaderRoutes);
|
app.register(registerReaderRoutes);
|
||||||
app.register(registerResourceManagerRoutes);
|
app.register(registerResourceManagerRoutes);
|
||||||
app.register(registerRuntimeRoutes);
|
app.register(registerRuntimeRoutes);
|
||||||
|
|||||||
560
etc/servers/work-server/src/routes/play-app.ts
Normal file
560
etc/servers/work-server/src/routes/play-app.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
|
||||||
|
type PlayAppEnvironment = 'preview' | 'test' | 'prod';
|
||||||
|
|
||||||
|
type PlayAppSeedEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accentClassName: string;
|
||||||
|
statusLabel: string;
|
||||||
|
isReady: boolean;
|
||||||
|
iconName: string;
|
||||||
|
usagePriority?: number;
|
||||||
|
supportedEnvironments: PlayAppEnvironment[];
|
||||||
|
searchKeywords?: string[];
|
||||||
|
searchDescription?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAY_APP_TABLE = 'play_apps';
|
||||||
|
const DEFAULT_ENTRIES: PlayAppSeedEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'baseball-ticket-bay',
|
||||||
|
name: '야구-티켓베이',
|
||||||
|
accentClassName: 'apps-library__card--baseball-ticket-bay',
|
||||||
|
statusLabel: '알림',
|
||||||
|
isReady: true,
|
||||||
|
iconName: 'BellOutlined',
|
||||||
|
usagePriority: 100,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['야구', '티켓베이', 'ticketbay', '야구 티켓', '웹푸시', '알림'],
|
||||||
|
searchDescription: '팀, 구역, 통로, 가격 조건으로 야구 티켓 알림 조건을 저장하고 테스트 푸시를 보냅니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'e-reader',
|
||||||
|
name: 'E-Reader',
|
||||||
|
accentClassName: 'apps-library__card--reader',
|
||||||
|
statusLabel: '읽기',
|
||||||
|
isReady: true,
|
||||||
|
iconName: 'BookOutlined',
|
||||||
|
usagePriority: 80,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'],
|
||||||
|
searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photoprism',
|
||||||
|
name: 'PhotoPrism',
|
||||||
|
accentClassName: 'apps-library__card--photoprism',
|
||||||
|
statusLabel: '연결',
|
||||||
|
isReady: true,
|
||||||
|
iconName: 'FileImageOutlined',
|
||||||
|
usagePriority: 70,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
|
||||||
|
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-puzzle',
|
||||||
|
name: '사진 퍼즐',
|
||||||
|
accentClassName: 'apps-library__card--puzzle',
|
||||||
|
statusLabel: '실행',
|
||||||
|
isReady: true,
|
||||||
|
iconName: 'PictureOutlined',
|
||||||
|
usagePriority: 60,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
|
||||||
|
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'the-quest',
|
||||||
|
name: 'The Quest',
|
||||||
|
accentClassName: 'apps-library__card--the-quest',
|
||||||
|
statusLabel: '신규',
|
||||||
|
isReady: true,
|
||||||
|
iconName: 'ThunderboltOutlined',
|
||||||
|
usagePriority: 50,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
|
||||||
|
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'template1',
|
||||||
|
name: 'Template1',
|
||||||
|
accentClassName: 'apps-library__card--template1',
|
||||||
|
statusLabel: '템플릿',
|
||||||
|
isReady: true,
|
||||||
|
iconName: 'AppstoreAddOutlined',
|
||||||
|
usagePriority: 45,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['template1', 'template', '앱 템플릿', '레이아웃', '기본 UI', 'layout'],
|
||||||
|
searchDescription: '다른 앱 개발 시 공통 레이아웃을 빠르게 적용하기 위한 템플릿 화면입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tetris',
|
||||||
|
name: 'Tetris',
|
||||||
|
accentClassName: 'apps-library__card--tetris',
|
||||||
|
statusLabel: '실행',
|
||||||
|
isReady: true,
|
||||||
|
iconName: 'FundProjectionScreenOutlined',
|
||||||
|
usagePriority: 40,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
|
||||||
|
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'beat-lab',
|
||||||
|
name: 'Beat Lab',
|
||||||
|
accentClassName: 'apps-library__card--beat',
|
||||||
|
statusLabel: '준비',
|
||||||
|
isReady: false,
|
||||||
|
iconName: 'SoundOutlined',
|
||||||
|
usagePriority: 35,
|
||||||
|
supportedEnvironments: ['preview'],
|
||||||
|
searchDescription: '향후 추가 예정인 Beat Lab 앱',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sticker-booth',
|
||||||
|
name: 'Sticker Booth',
|
||||||
|
accentClassName: 'apps-library__card--sticker',
|
||||||
|
statusLabel: '준비',
|
||||||
|
isReady: false,
|
||||||
|
iconName: 'StarOutlined',
|
||||||
|
usagePriority: 30,
|
||||||
|
supportedEnvironments: ['preview'],
|
||||||
|
searchDescription: '향후 추가 예정인 Sticker Booth 앱',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launch-note',
|
||||||
|
name: 'Launch Note',
|
||||||
|
accentClassName: 'apps-library__card--launch',
|
||||||
|
statusLabel: '예정',
|
||||||
|
isReady: false,
|
||||||
|
iconName: 'RocketOutlined',
|
||||||
|
usagePriority: 20,
|
||||||
|
supportedEnvironments: ['preview'],
|
||||||
|
searchDescription: '향후 추가 예정인 Launch Note 앱',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'arcade-pack',
|
||||||
|
name: 'Arcade Pack',
|
||||||
|
accentClassName: 'apps-library__card--arcade',
|
||||||
|
statusLabel: '예정',
|
||||||
|
isReady: false,
|
||||||
|
iconName: 'FireOutlined',
|
||||||
|
usagePriority: 10,
|
||||||
|
supportedEnvironments: ['preview'],
|
||||||
|
searchDescription: '향후 추가 예정인 Arcade Pack 앱',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-vault',
|
||||||
|
name: 'App Vault',
|
||||||
|
accentClassName: 'apps-library__card--vault',
|
||||||
|
statusLabel: '테마',
|
||||||
|
isReady: false,
|
||||||
|
iconName: 'AppstoreOutlined',
|
||||||
|
usagePriority: 0,
|
||||||
|
supportedEnvironments: ['preview'],
|
||||||
|
searchDescription: '향후 추가 예정인 App Vault 앱',
|
||||||
|
},
|
||||||
|
] ;
|
||||||
|
|
||||||
|
const listQuerySchema = z.object({
|
||||||
|
environment: z.enum(['preview', 'test', 'prod']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playAppIdSchema = z.string().trim().min(1).max(100);
|
||||||
|
|
||||||
|
const supportedEnvironmentSchema = z.enum(['preview', 'test', 'prod']);
|
||||||
|
|
||||||
|
const iconNameSchema = z.enum([
|
||||||
|
'AppstoreOutlined',
|
||||||
|
'AppstoreAddOutlined',
|
||||||
|
'BellOutlined',
|
||||||
|
'BookOutlined',
|
||||||
|
'FireOutlined',
|
||||||
|
'FundProjectionScreenOutlined',
|
||||||
|
'FileImageOutlined',
|
||||||
|
'PictureOutlined',
|
||||||
|
'RocketOutlined',
|
||||||
|
'SoundOutlined',
|
||||||
|
'StarOutlined',
|
||||||
|
'ThunderboltOutlined',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const playAppCreatePayloadSchema = z.object({
|
||||||
|
id: playAppIdSchema,
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
accentClassName: z.string().trim().min(1).max(80),
|
||||||
|
statusLabel: z.string().trim().min(1).max(80),
|
||||||
|
isReady: z.boolean().default(false),
|
||||||
|
iconName: iconNameSchema,
|
||||||
|
usagePriority: z.number().int().min(0).max(1_000_000).optional(),
|
||||||
|
supportedEnvironments: z.array(supportedEnvironmentSchema).min(1).default(['preview']),
|
||||||
|
searchKeywords: z
|
||||||
|
.array(z.string().trim().min(1).max(80))
|
||||||
|
.default([])
|
||||||
|
.transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0)))),
|
||||||
|
searchDescription: z.string().trim().max(4000).default(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playAppUpdatePayloadSchema = z
|
||||||
|
.object({
|
||||||
|
id: playAppIdSchema.optional(),
|
||||||
|
name: z.string().trim().min(1).max(120).optional(),
|
||||||
|
accentClassName: z.string().trim().min(1).max(80).optional(),
|
||||||
|
statusLabel: z.string().trim().min(1).max(80).optional(),
|
||||||
|
isReady: z.boolean().optional(),
|
||||||
|
iconName: iconNameSchema.optional(),
|
||||||
|
usagePriority: z.number().int().min(0).max(1_000_000).nullable().optional(),
|
||||||
|
supportedEnvironments: z.array(supportedEnvironmentSchema).optional(),
|
||||||
|
searchKeywords: z
|
||||||
|
.array(z.string().trim().min(1).max(80))
|
||||||
|
.transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0))))
|
||||||
|
.optional(),
|
||||||
|
searchDescription: z.string().trim().max(4000).optional(),
|
||||||
|
})
|
||||||
|
.refine((payload) => Object.keys(payload).length > 0, {
|
||||||
|
message: '수정할 항목이 없습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const playAppIdParamsSchema = z.object({
|
||||||
|
id: playAppIdSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
type SupportedEnvironment = Array<PlayAppEnvironment>;
|
||||||
|
type PlayAppRegistryRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accent_class_name: string;
|
||||||
|
status_label: string;
|
||||||
|
is_ready: boolean;
|
||||||
|
icon_name: string;
|
||||||
|
usage_priority: number | null;
|
||||||
|
supported_environments: string | null;
|
||||||
|
search_keywords: string | null;
|
||||||
|
search_description: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlayAppRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accentClassName: string;
|
||||||
|
statusLabel: string;
|
||||||
|
isReady: boolean;
|
||||||
|
iconName: string;
|
||||||
|
usagePriority?: number;
|
||||||
|
supportedEnvironments: SupportedEnvironment;
|
||||||
|
searchKeywords: string[];
|
||||||
|
searchDescription: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlayAppCreateInput = z.infer<typeof playAppCreatePayloadSchema>;
|
||||||
|
type PlayAppUpdateInput = z.infer<typeof playAppUpdatePayloadSchema>;
|
||||||
|
|
||||||
|
function normalizeSupportedEnvironments(value: string | null): SupportedEnvironment {
|
||||||
|
if (!value) {
|
||||||
|
return ['preview'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return ['preview'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.map((item) => String(item).trim())
|
||||||
|
.filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to comma parser below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArrayList(value: string | null): string[] {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallthrough to legacy parser below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResponseItem(row: PlayAppRegistryRecord): PlayAppRow {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
accentClassName: row.accent_class_name,
|
||||||
|
statusLabel: row.status_label,
|
||||||
|
isReady: !!row.is_ready,
|
||||||
|
iconName: row.icon_name,
|
||||||
|
usagePriority: row.usage_priority ?? undefined,
|
||||||
|
supportedEnvironments: normalizeSupportedEnvironments(row.supported_environments),
|
||||||
|
searchKeywords: parseJsonArrayList(row.search_keywords),
|
||||||
|
searchDescription: row.search_description ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeJsonList(values: readonly string[] | null | undefined) {
|
||||||
|
if (!values || values.length === 0) {
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDbRowPayload(input: PlayAppCreateInput | Omit<PlayAppUpdateInput, 'id'>) {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
id: 'id' in input ? input.id?.trim() : undefined,
|
||||||
|
name: input.name?.trim(),
|
||||||
|
accent_class_name: input.accentClassName?.trim(),
|
||||||
|
status_label: input.statusLabel?.trim(),
|
||||||
|
is_ready: input.isReady,
|
||||||
|
icon_name: input.iconName,
|
||||||
|
usage_priority: input.usagePriority ?? null,
|
||||||
|
supported_environments: input.supportedEnvironments ? encodeJsonList(input.supportedEnvironments) : undefined,
|
||||||
|
search_keywords: input.searchKeywords ? encodeJsonList(input.searchKeywords) : undefined,
|
||||||
|
search_description: input.searchDescription ? input.searchDescription.trim() : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete payload[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('is_ready' in payload && payload.is_ready === undefined) {
|
||||||
|
payload.is_ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDbUpdateResultEmpty(result: unknown) {
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
return result.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof result === 'number' ? result === 0 : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlayAppErrorWithCode(error: unknown, fallbackMessage: string) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const code = (error as { code?: string }).code;
|
||||||
|
if (code === 'ER_DUP_ENTRY' || code === '23505') {
|
||||||
|
const duplicateError = error as Error & { statusCode?: number; details?: string };
|
||||||
|
duplicateError.statusCode = 409;
|
||||||
|
duplicateError.message = '이미 등록된 앱 ID입니다.';
|
||||||
|
return duplicateError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const duplicateError = error as Error & { statusCode?: number; details?: string };
|
||||||
|
duplicateError.message = fallbackMessage;
|
||||||
|
return duplicateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Error(fallbackMessage) as Error & { statusCode?: number };
|
||||||
|
next.statusCode = 500;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePlayAppTable() {
|
||||||
|
const exists = await db.schema.hasTable(PLAY_APP_TABLE);
|
||||||
|
if (!exists) {
|
||||||
|
await db.schema.createTable(PLAY_APP_TABLE, (table) => {
|
||||||
|
table.string('id', 100).primary();
|
||||||
|
table.string('name', 120).notNullable();
|
||||||
|
table.string('accent_class_name', 80).notNullable();
|
||||||
|
table.string('status_label', 80).notNullable();
|
||||||
|
table.boolean('is_ready').notNullable().defaultTo(false);
|
||||||
|
table.string('icon_name', 80).notNullable();
|
||||||
|
table.integer('usage_priority').nullable();
|
||||||
|
table.text('supported_environments').nullable();
|
||||||
|
table.text('search_keywords').nullable();
|
||||||
|
table.text('search_description').nullable();
|
||||||
|
table.index('is_ready', 'play_apps_is_ready_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRows = await db(PLAY_APP_TABLE).select('id');
|
||||||
|
const existingIds = new Set(existingRows.map((row) => row.id));
|
||||||
|
|
||||||
|
const rowsToInsert = DEFAULT_ENTRIES.filter((entry) => !existingIds.has(entry.id)).map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
accent_class_name: entry.accentClassName,
|
||||||
|
status_label: entry.statusLabel,
|
||||||
|
is_ready: entry.isReady,
|
||||||
|
icon_name: entry.iconName,
|
||||||
|
usage_priority: entry.usagePriority,
|
||||||
|
supported_environments: encodeJsonList(entry.supportedEnvironments),
|
||||||
|
search_keywords: encodeJsonList(entry.searchKeywords ?? []),
|
||||||
|
search_description: entry.searchDescription ?? '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (rowsToInsert.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db(PLAY_APP_TABLE).insert(rowsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPlayAppEntries(environment?: PlayAppEnvironment | null) {
|
||||||
|
await ensurePlayAppTable();
|
||||||
|
|
||||||
|
const rows = (await db(PLAY_APP_TABLE)
|
||||||
|
.select<PlayAppRegistryRecord[]>('*')
|
||||||
|
.orderBy('usage_priority', 'desc')
|
||||||
|
.orderBy('id', 'asc')) as PlayAppRegistryRecord[];
|
||||||
|
|
||||||
|
const normalizedRows = rows.map(toResponseItem);
|
||||||
|
const filteredRows = environment ? normalizedRows.filter((row) => row.supportedEnvironments.includes(environment)) : normalizedRows;
|
||||||
|
|
||||||
|
return filteredRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPlayAppEntry(body: unknown) {
|
||||||
|
await ensurePlayAppTable();
|
||||||
|
|
||||||
|
const payload = playAppCreatePayloadSchema.parse(body);
|
||||||
|
const dbPayload = toDbRowPayload(payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db(PLAY_APP_TABLE).insert(dbPayload);
|
||||||
|
} catch (error) {
|
||||||
|
throw parsePlayAppErrorWithCode(error, `앱 등록에 실패했습니다: ${payload.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedRow = await db(PLAY_APP_TABLE)
|
||||||
|
.where({ id: payload.id })
|
||||||
|
.first<PlayAppRegistryRecord>();
|
||||||
|
|
||||||
|
if (!insertedRow) {
|
||||||
|
const notFoundError = new Error('등록된 앱을 조회하지 못했습니다.') as Error & { statusCode?: number };
|
||||||
|
notFoundError.statusCode = 500;
|
||||||
|
throw notFoundError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item: toResponseItem(insertedRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePlayAppEntry(params: unknown, body: unknown) {
|
||||||
|
await ensurePlayAppTable();
|
||||||
|
|
||||||
|
const { id } = playAppIdParamsSchema.parse(params);
|
||||||
|
const payload = playAppUpdatePayloadSchema.parse(body);
|
||||||
|
|
||||||
|
if (payload.id && payload.id !== id) {
|
||||||
|
const invalidError = new Error('요청 경로 ID와 본문 ID가 일치하지 않습니다.') as Error & { statusCode?: number };
|
||||||
|
invalidError.statusCode = 409;
|
||||||
|
throw invalidError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPayload = toDbRowPayload(payload);
|
||||||
|
const updated = await db(PLAY_APP_TABLE).where({ id }).update(dbPayload as Record<string, unknown>);
|
||||||
|
|
||||||
|
if (isDbUpdateResultEmpty(updated)) {
|
||||||
|
const notFoundError = new Error(`수정할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||||
|
notFoundError.statusCode = 404;
|
||||||
|
throw notFoundError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRow = await db(PLAY_APP_TABLE).where({ id }).first<PlayAppRegistryRecord>();
|
||||||
|
if (!updatedRow) {
|
||||||
|
const notFoundError = new Error(`수정한 앱을 조회할 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||||
|
notFoundError.statusCode = 404;
|
||||||
|
throw notFoundError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
item: toResponseItem(updatedRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlayAppEntry(params: unknown) {
|
||||||
|
await ensurePlayAppTable();
|
||||||
|
|
||||||
|
const { id } = playAppIdParamsSchema.parse(params);
|
||||||
|
const deleted = await db(PLAY_APP_TABLE).where({ id }).delete('*');
|
||||||
|
if (isDbUpdateResultEmpty(deleted)) {
|
||||||
|
const notFoundError = new Error(`삭제할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||||
|
notFoundError.statusCode = 404;
|
||||||
|
throw notFoundError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(deleted) && deleted[0]) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
deletedId: id,
|
||||||
|
item: toResponseItem(deleted[0] as PlayAppRegistryRecord),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
deletedId: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerPlayAppRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/api/play-apps', async (request) => {
|
||||||
|
const query = listQuerySchema.parse(request.query);
|
||||||
|
|
||||||
|
const items = await listPlayAppEntries(query.environment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/play-apps', async (request) => {
|
||||||
|
return createPlayAppEntry(request.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/play-apps/:id', async (request) => {
|
||||||
|
return updatePlayAppEntry(request.params, request.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/play-apps/:id', async (request) => {
|
||||||
|
return deletePlayAppEntry(request.params);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { PhotoPuzzleAppView } from '../../views/play/apps/photo-puzzle/PhotoPuzz
|
|||||||
import { PhotoPrismAppView } from '../../views/play/apps/photoprism/PhotoPrismAppView';
|
import { PhotoPrismAppView } from '../../views/play/apps/photoprism/PhotoPrismAppView';
|
||||||
import { TheQuestAppView } from '../../views/play/apps/the-quest/TheQuestAppView';
|
import { TheQuestAppView } from '../../views/play/apps/the-quest/TheQuestAppView';
|
||||||
import { TetrisAppView } from '../../views/play/apps/tetris/TetrisAppView';
|
import { TetrisAppView } from '../../views/play/apps/tetris/TetrisAppView';
|
||||||
|
import { Template1PlayAppView } from '../../views/play/apps/template1/Template1PlayAppView';
|
||||||
|
|
||||||
type PlayAppOverlayProps = {
|
type PlayAppOverlayProps = {
|
||||||
appId: string;
|
appId: string;
|
||||||
@@ -41,6 +42,10 @@ function renderPlayApp(appId: string, onClose: () => void) {
|
|||||||
return <TheQuestAppView onBack={onClose} launchContext="embedded" />;
|
return <TheQuestAppView onBack={onClose} launchContext="embedded" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appId === 'template1') {
|
||||||
|
return <Template1PlayAppView onBack={onClose} launchContext="embedded" />;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
CopyOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
@@ -21,7 +22,6 @@ import { StepperUI } from '../../../components/stepper';
|
|||||||
import { InlineImage } from '../../../components/common/InlineImage';
|
import { InlineImage } from '../../../components/common/InlineImage';
|
||||||
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
|
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
|
||||||
import { FullscreenPreviewModal, ZoomablePreviewSurface } from '../../../components/previewer';
|
import { FullscreenPreviewModal, ZoomablePreviewSurface } from '../../../components/previewer';
|
||||||
import { renderEditorBlock } from '../../../components/previewer/renderers';
|
|
||||||
import { ChatPreviewBody } from './ChatPreviewBody';
|
import { ChatPreviewBody } from './ChatPreviewBody';
|
||||||
import { isMarkdownContentType, isMarkdownResourceUrl, normalizeChatResourceUrl } from './chatResourceUrl';
|
import { isMarkdownContentType, isMarkdownResourceUrl, normalizeChatResourceUrl } from './chatResourceUrl';
|
||||||
import { sharePreviewLink } from './chatUtils';
|
import { sharePreviewLink } from './chatUtils';
|
||||||
@@ -507,7 +507,7 @@ function canShowHtmlPreviewActions(preview: PromptPreview | null | undefined) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preview.type === 'html') {
|
if (preview.type === 'editable' || preview.type === 'markdown' || preview.type === 'html') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +516,10 @@ function canShowHtmlPreviewActions(preview: PromptPreview | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (preview.content?.trim()) {
|
if (preview.content?.trim()) {
|
||||||
return /<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(preview.content);
|
return (
|
||||||
|
/<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(preview.content) ||
|
||||||
|
isMarkdownPromptPreview(preview)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean(preview.url && isHtmlLikeUrl(preview.url));
|
return Boolean(preview.url && isHtmlLikeUrl(preview.url));
|
||||||
@@ -651,7 +654,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
|
|||||||
setLoadError('');
|
setLoadError('');
|
||||||
|
|
||||||
const shouldFetchTextPreview =
|
const shouldFetchTextPreview =
|
||||||
previewType === 'markdown' || previewType === 'html';
|
previewType === 'markdown' || previewType === 'html' || previewType === 'editable';
|
||||||
const shouldInspectResourcePreview =
|
const shouldInspectResourcePreview =
|
||||||
previewType === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
|
previewType === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
|
||||||
|
|
||||||
@@ -732,15 +735,37 @@ export function PromptPreviewSurface({
|
|||||||
preview,
|
preview,
|
||||||
compact = false,
|
compact = false,
|
||||||
htmlMode = 'preview',
|
htmlMode = 'preview',
|
||||||
|
sourceContent,
|
||||||
|
onSourceContentChange,
|
||||||
}: {
|
}: {
|
||||||
preview: PromptPreview;
|
preview: PromptPreview;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
htmlMode?: 'preview' | 'source';
|
htmlMode?: 'preview' | 'source';
|
||||||
|
sourceContent?: string;
|
||||||
|
onSourceContentChange?: (content: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { remoteContent, remoteContentType, isLoading, loadError } = usePromptPreviewContent(preview);
|
const { remoteContent, remoteContentType, isLoading, loadError } = usePromptPreviewContent(preview);
|
||||||
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview.url);
|
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview.url);
|
||||||
const shouldRenderAsHtml = shouldRenderAsHtmlDocument(preview, remoteContentType, remoteContent);
|
const shouldRenderAsHtml = shouldRenderAsHtmlDocument(preview, remoteContentType, remoteContent);
|
||||||
const htmlDocument = shouldRenderAsHtml ? buildHtmlFrameDocument(remoteContent || '', normalizedPreviewUrl || preview.url) : null;
|
const htmlDocument = shouldRenderAsHtml ? buildHtmlFrameDocument(remoteContent || '', normalizedPreviewUrl || preview.url) : null;
|
||||||
|
const defaultSourceContent = remoteContent ?? preview.content ?? '';
|
||||||
|
const sourceValue = sourceContent ?? defaultSourceContent;
|
||||||
|
const markdownValue = remoteContent ?? '표시할 markdown preview가 없습니다.';
|
||||||
|
const [editableSourceContent, setEditableSourceContent] = useState(defaultSourceContent);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onSourceContentChange || htmlMode !== 'source') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceContent === undefined || sourceContent.length === 0) {
|
||||||
|
onSourceContentChange(defaultSourceContent);
|
||||||
|
}
|
||||||
|
}, [defaultSourceContent, htmlMode, onSourceContentChange, sourceContent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditableSourceContent(defaultSourceContent);
|
||||||
|
}, [defaultSourceContent]);
|
||||||
|
|
||||||
if (preview.type === 'image' && normalizedPreviewUrl) {
|
if (preview.type === 'image' && normalizedPreviewUrl) {
|
||||||
const imageNode = (
|
const imageNode = (
|
||||||
@@ -776,9 +801,41 @@ export function PromptPreviewSurface({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (preview.type === 'markdown') {
|
if (preview.type === 'markdown') {
|
||||||
|
if (htmlMode === 'source') {
|
||||||
|
return (
|
||||||
|
<div className="app-chat-prompt-card__preview-source">
|
||||||
|
<Input.TextArea
|
||||||
|
value={sourceValue}
|
||||||
|
onChange={(event) => onSourceContentChange?.(event.currentTarget.value)}
|
||||||
|
autoSize={false}
|
||||||
|
className="app-chat-prompt-card__preview-source-editor"
|
||||||
|
placeholder="표시할 markdown 소스가 없습니다."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-chat-prompt-card__preview-markdown">
|
<div className="app-chat-prompt-card__preview-markdown">
|
||||||
<MarkdownPreviewContent content={remoteContent || '표시할 markdown preview가 없습니다.'} maxBlocks={compact ? 5 : undefined} />
|
<MarkdownPreviewContent content={markdownValue} maxBlocks={compact ? 5 : undefined} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.type === 'editable') {
|
||||||
|
return (
|
||||||
|
<div className="app-chat-prompt-card__preview-source">
|
||||||
|
<Input.TextArea
|
||||||
|
value={editableSourceContent}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.currentTarget.value;
|
||||||
|
setEditableSourceContent(nextValue);
|
||||||
|
onSourceContentChange?.(nextValue);
|
||||||
|
}}
|
||||||
|
autoSize={false}
|
||||||
|
className="app-chat-prompt-card__preview-source-editor"
|
||||||
|
placeholder="편집 가능한 소스 블록입니다."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -804,8 +861,14 @@ export function PromptPreviewSurface({
|
|||||||
|
|
||||||
if (htmlMode === 'source') {
|
if (htmlMode === 'source') {
|
||||||
return (
|
return (
|
||||||
<div className="app-chat-prompt-card__preview-code">
|
<div className="app-chat-prompt-card__preview-source">
|
||||||
{renderEditorBlock(htmlContent || '표시할 HTML 코드가 없습니다.', 'html', 'code')}
|
<Input.TextArea
|
||||||
|
value={sourceValue}
|
||||||
|
onChange={(event) => onSourceContentChange?.(event.currentTarget.value)}
|
||||||
|
autoSize={false}
|
||||||
|
className="app-chat-prompt-card__preview-source-editor"
|
||||||
|
placeholder="표시할 HTML 소스가 없습니다."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1200,6 +1263,7 @@ export function ChatPromptCard({
|
|||||||
const [submittedFreeText, setSubmittedFreeText] = useState('');
|
const [submittedFreeText, setSubmittedFreeText] = useState('');
|
||||||
const [expandedOptionValue, setExpandedOptionValue] = useState<string | null>(null);
|
const [expandedOptionValue, setExpandedOptionValue] = useState<string | null>(null);
|
||||||
const [expandedHtmlMode, setExpandedHtmlMode] = useState<'preview' | 'source'>('preview');
|
const [expandedHtmlMode, setExpandedHtmlMode] = useState<'preview' | 'source'>('preview');
|
||||||
|
const [expandedPreviewSource, setExpandedPreviewSource] = useState('');
|
||||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||||
const [attachments, setAttachments] = useState<ChatComposerAttachment[]>([]);
|
const [attachments, setAttachments] = useState<ChatComposerAttachment[]>([]);
|
||||||
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false);
|
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false);
|
||||||
@@ -1242,6 +1306,7 @@ export function ChatPromptCard({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpandedHtmlMode('preview');
|
setExpandedHtmlMode('preview');
|
||||||
|
setExpandedPreviewSource(expandedOption?.preview?.content ?? '');
|
||||||
}, [expandedOptionValue]);
|
}, [expandedOptionValue]);
|
||||||
const activeStep = steps[Math.min(activeStepIndex, Math.max(steps.length - 1, 0))] ?? steps[0];
|
const activeStep = steps[Math.min(activeStepIndex, Math.max(steps.length - 1, 0))] ?? steps[0];
|
||||||
const activeSelection = activeStep ? stepSelections[activeStep.key] : undefined;
|
const activeSelection = activeStep ? stepSelections[activeStep.key] : undefined;
|
||||||
@@ -1274,6 +1339,7 @@ export function ChatPromptCard({
|
|||||||
const progressPayload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
|
const progressPayload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
|
||||||
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
|
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
|
||||||
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
|
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
|
||||||
|
const isExpandedEditablePreview = expandedOption?.preview?.type === 'editable';
|
||||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1364,6 +1430,19 @@ export function ChatPromptCard({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyExpandedEditablePreview = async () => {
|
||||||
|
if (!expandedOption?.preview || !isExpandedEditablePreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(expandedPreviewSource);
|
||||||
|
message.success('편집 내용을 복사했습니다.');
|
||||||
|
} catch {
|
||||||
|
message.error('편집 내용을 복사할 수 없습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const uploadAttachments = async (files: File[]) => {
|
const uploadAttachments = async (files: File[]) => {
|
||||||
if (!allowAttachments || !onUploadAttachment || isLocked || isSubmitting || isUploadingAttachment || files.length === 0) {
|
if (!allowAttachments || !onUploadAttachment || isLocked || isSubmitting || isUploadingAttachment || files.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1925,21 +2004,33 @@ export function ChatPromptCard({
|
|||||||
icon={<ShareAltOutlined />}
|
icon={<ShareAltOutlined />}
|
||||||
onClick={handleShareExpandedPreview}
|
onClick={handleShareExpandedPreview}
|
||||||
/>
|
/>
|
||||||
|
{isExpandedEditablePreview ? (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
className="fullscreen-preview-modal__icon-button"
|
className="fullscreen-preview-modal__icon-button"
|
||||||
aria-label="HTML 실행 미리보기"
|
aria-label="편집 내용 복사"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={handleCopyExpandedEditablePreview}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="fullscreen-preview-modal__icon-button"
|
||||||
|
aria-label="미리보기 화면"
|
||||||
icon={<EyeOutlined />}
|
icon={<EyeOutlined />}
|
||||||
onClick={() => setExpandedHtmlMode('preview')}
|
onClick={() => setExpandedHtmlMode('preview')}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
className="fullscreen-preview-modal__icon-button"
|
className="fullscreen-preview-modal__icon-button"
|
||||||
aria-label="HTML 코드 보기"
|
aria-label="소스 보기"
|
||||||
icon={<CodeOutlined />}
|
icon={<CodeOutlined />}
|
||||||
onClick={() => setExpandedHtmlMode('source')}
|
onClick={() => setExpandedHtmlMode('source')}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1955,7 +2046,14 @@ export function ChatPromptCard({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="app-chat-prompt-card__preview-modal-surface">
|
<div className="app-chat-prompt-card__preview-modal-surface">
|
||||||
{expandedOption?.preview ? <PromptPreviewSurface preview={expandedOption.preview} htmlMode={expandedHtmlMode} /> : null}
|
{expandedOption?.preview ? (
|
||||||
|
<PromptPreviewSurface
|
||||||
|
preview={expandedOption.preview}
|
||||||
|
htmlMode={expandedHtmlMode}
|
||||||
|
sourceContent={expandedPreviewSource}
|
||||||
|
onSourceContentChange={setExpandedPreviewSource}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</FullscreenPreviewModal>
|
</FullscreenPreviewModal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -256,6 +256,47 @@ type PromptPreview = NonNullable<
|
|||||||
type PromptOption = Extract<ChatMessagePart, { type: 'prompt' }>['options'][number];
|
type PromptOption = Extract<ChatMessagePart, { type: 'prompt' }>['options'][number];
|
||||||
type PromptStep = NonNullable<Extract<ChatMessagePart, { type: 'prompt' }>['steps']>[number];
|
type PromptStep = NonNullable<Extract<ChatMessagePart, { type: 'prompt' }>['steps']>[number];
|
||||||
|
|
||||||
|
function normalizePromptPreviewType(typeValue: string | null | undefined, url: string, content: string) {
|
||||||
|
const normalizedType = normalizeOptionalText(typeValue).toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedType === 'image' || normalizedType === 'markdown' || normalizedType === 'html' || normalizedType === 'resource') {
|
||||||
|
return normalizedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType === 'md' || normalizedType === 'text' || normalizedType === 'txt' || normalizedType === 'plain') {
|
||||||
|
return 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType === 'htm') {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedContent = normalizeOptionalText(content).trim();
|
||||||
|
const normalizedUrl = normalizeOptionalText(url).toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedUrl.endsWith('.md') || normalizedUrl.endsWith('.markdown')) {
|
||||||
|
return 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedUrl.endsWith('.html') || normalizedUrl.endsWith('.htm')) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(normalizedContent)) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^#{1,6}\s|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s+|\[[^\]]+\]\([^)]+\)/m.test(normalizedContent)) {
|
||||||
|
return 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'resource';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePromptPreview(
|
function normalizePromptPreview(
|
||||||
preview: {
|
preview: {
|
||||||
type?: string | null;
|
type?: string | null;
|
||||||
@@ -269,9 +310,9 @@ function normalizePromptPreview(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = preview.type === 'image' || preview.type === 'markdown' || preview.type === 'html' || preview.type === 'resource'
|
const normalizedUrl = normalizeOptionalText(preview.url);
|
||||||
? preview.type
|
const normalizedContent = normalizeOptionalText(preview.content);
|
||||||
: null;
|
const type = normalizePromptPreviewType(preview.type, normalizedUrl, normalizedContent);
|
||||||
|
|
||||||
if (!type) {
|
if (!type) {
|
||||||
return null;
|
return null;
|
||||||
@@ -279,8 +320,8 @@ function normalizePromptPreview(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
url: normalizeOptionalText(preview.url),
|
url: normalizedUrl,
|
||||||
content: normalizeOptionalText(preview.content),
|
content: normalizedContent,
|
||||||
alt: normalizeOptionalText(preview.alt),
|
alt: normalizeOptionalText(preview.alt),
|
||||||
title: normalizeOptionalText(preview.title),
|
title: normalizeOptionalText(preview.title),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,68 @@ function normalizeText(value: unknown) {
|
|||||||
return String(value ?? '').trim();
|
return String(value ?? '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePromptPreviewType(typeValue: unknown, url: string, content: string, editableMode = false) {
|
||||||
|
const normalizedType = normalizeText(typeValue).toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedType === 'image' ||
|
||||||
|
normalizedType === 'md' ||
|
||||||
|
normalizedType === 'markdown' ||
|
||||||
|
normalizedType === 'html' ||
|
||||||
|
normalizedType === 'resource' ||
|
||||||
|
normalizedType === 'editable' ||
|
||||||
|
normalizedType === 'editor' ||
|
||||||
|
normalizedType === 'code'
|
||||||
|
) {
|
||||||
|
if (normalizedType === 'editable' || normalizedType === 'editor' || normalizedType === 'code') {
|
||||||
|
return 'editable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType === 'text' || normalizedType === 'plain' || normalizedType === 'txt') {
|
||||||
|
return editableMode ? 'editable' : 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType === 'htm') {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editableMode && (url || content)) {
|
||||||
|
return 'editable';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editableMode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedContent = String(content ?? '').trim();
|
||||||
|
const normalizedUrl = normalizeUrl(url).toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedUrl.endsWith('.md') || normalizedUrl.endsWith('.markdown')) {
|
||||||
|
return 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedUrl.endsWith('.html') || normalizedUrl.endsWith('.htm')) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(normalizedContent)) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^#{1,6}\s|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s+|\[[^\]]+\]\([^)]+\)/m.test(normalizedContent)) {
|
||||||
|
return 'markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'resource';
|
||||||
|
}
|
||||||
|
|
||||||
function unwrapMarkdownLinkTarget(value: string) {
|
function unwrapMarkdownLinkTarget(value: string) {
|
||||||
const normalized = normalizeText(value);
|
const normalized = normalizeText(value);
|
||||||
|
|
||||||
@@ -308,24 +370,26 @@ function normalizePromptPreview(value: unknown): PromptPreview | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = value as Record<string, unknown>;
|
const record = value as Record<string, unknown>;
|
||||||
const type =
|
|
||||||
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
|
|
||||||
? record.type
|
|
||||||
: null;
|
|
||||||
const url = normalizeUrl(normalizeText(record.url));
|
const url = normalizeUrl(normalizeText(record.url));
|
||||||
const content = String(record.content ?? '').trim() || null;
|
const content = String(record.content ?? '').trim() || null;
|
||||||
const alt = normalizeText(record.alt) || null;
|
const alt = normalizeText(record.alt) || null;
|
||||||
const title = normalizeText(record.title) || null;
|
const title = normalizeText(record.title) || null;
|
||||||
|
const editable = record.editable === true;
|
||||||
|
const type = normalizePromptPreviewType(record.type, url, content || '', editable);
|
||||||
|
|
||||||
if (!type) {
|
if (!type) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'image' || type === 'resource') {
|
if (type === 'image' || (type === 'resource' && !editable)) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else if (!content && !url) {
|
} else if (type !== 'editable' && !content && !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'editable' && !content && !url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +399,7 @@ function normalizePromptPreview(value: unknown): PromptPreview | null {
|
|||||||
content,
|
content,
|
||||||
alt,
|
alt,
|
||||||
title,
|
title,
|
||||||
|
editable: editable || type === 'editable' ? true : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2605,6 +2605,37 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-chat-prompt-card__preview-source {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-prompt-card__preview-source-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 140px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 0;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
box-shadow: none;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-chat-prompt-card__preview-source-editor:hover,
|
||||||
|
.app-chat-prompt-card__preview-source-editor:focus,
|
||||||
|
.app-chat-prompt-card__preview-source-editor:focus-visible {
|
||||||
|
border-color: rgba(13, 148, 136, 0.58);
|
||||||
|
box-shadow: 0 0 0 2px rgba(13, 148, 136, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
.app-chat-prompt-card__preview-code .previewer-ui__editor,
|
.app-chat-prompt-card__preview-code .previewer-ui__editor,
|
||||||
.app-chat-prompt-card__preview-code .previewer-ui__editor-body {
|
.app-chat-prompt-card__preview-code .previewer-ui__editor-body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ export type ChatComposerAttachment = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ChatStructuredPreview = {
|
export type ChatStructuredPreview = {
|
||||||
type: 'image' | 'markdown' | 'html' | 'resource';
|
type: 'image' | 'markdown' | 'html' | 'resource' | 'editable';
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
alt?: string | null;
|
alt?: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
editable?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatPromptContextRef = {
|
export type ChatPromptContextRef = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons';
|
import * as AntdIcons from '@ant-design/icons';
|
||||||
|
import { AppstoreOutlined, CheckOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd';
|
import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd';
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||||
import { Suspense, lazy, startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
import { Suspense, lazy, startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||||
@@ -19,6 +20,7 @@ import { PhotoPuzzleAppView } from '../../../views/play/apps/photo-puzzle/PhotoP
|
|||||||
import { PhotoPrismAppView } from '../../../views/play/apps/photoprism/PhotoPrismAppView';
|
import { PhotoPrismAppView } from '../../../views/play/apps/photoprism/PhotoPrismAppView';
|
||||||
import { TetrisAppView } from '../../../views/play/apps/tetris/TetrisAppView';
|
import { TetrisAppView } from '../../../views/play/apps/tetris/TetrisAppView';
|
||||||
import { TheQuestAppView } from '../../../views/play/apps/the-quest/TheQuestAppView';
|
import { TheQuestAppView } from '../../../views/play/apps/the-quest/TheQuestAppView';
|
||||||
|
import { Template1PlayAppView } from '../../../views/play/apps/template1/Template1PlayAppView';
|
||||||
import { SharedResourceManagementPage } from '../SharedResourceManagementPage';
|
import { SharedResourceManagementPage } from '../SharedResourceManagementPage';
|
||||||
import { SharedAppSettingsPage } from '../SharedAppSettingsPage';
|
import { SharedAppSettingsPage } from '../SharedAppSettingsPage';
|
||||||
import { TokenSettingManagementPage } from '../TokenSettingManagementPage';
|
import { TokenSettingManagementPage } from '../TokenSettingManagementPage';
|
||||||
@@ -96,6 +98,8 @@ import './ChatSharePage.css';
|
|||||||
|
|
||||||
const { Paragraph, Text, Title } = Typography;
|
const { Paragraph, Text, Title } = Typography;
|
||||||
|
|
||||||
|
const ShareCloseIcon = AntdIcons.CloseOutlined || AntdIcons.CloseCircleOutlined || (() => <span aria-hidden="true">×</span>);
|
||||||
|
|
||||||
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources/';
|
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources/';
|
||||||
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
||||||
const CHAT_PUBLIC_DOT_CODEX_PREFIX = '/public/.codex_chat/';
|
const CHAT_PUBLIC_DOT_CODEX_PREFIX = '/public/.codex_chat/';
|
||||||
@@ -114,11 +118,11 @@ const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6;
|
|||||||
const SHARE_ROOM_SWITCH_CACHE_REQUEST_LIMIT = 12;
|
const SHARE_ROOM_SWITCH_CACHE_REQUEST_LIMIT = 12;
|
||||||
const SHARE_ROOM_SWITCH_CACHE_MESSAGE_LIMIT = 32;
|
const SHARE_ROOM_SWITCH_CACHE_MESSAGE_LIMIT = 32;
|
||||||
const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000;
|
const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000;
|
||||||
const SHARE_EDGE_NAVIGATION_HOTZONE_PX = 28;
|
const SHARE_EDGE_NAVIGATION_HOTZONE_PX = 38;
|
||||||
const SHARE_APPS_EDGE_MIDDLE_BAND_RATIO = 0.2;
|
|
||||||
const SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX = 16;
|
const SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX = 16;
|
||||||
const SHARE_EDGE_GESTURE_OPEN_APPS_PX = 96;
|
const SHARE_EDGE_GESTURE_OPEN_APPS_PX = 100;
|
||||||
const SHARE_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX = 64;
|
const SHARE_EDGE_APPS_GESTURE_HOTZONE_PX = 110;
|
||||||
|
const SHARE_MINIMIZED_ACTION_TAP_TOLERANCE_PX = 8;
|
||||||
const SHARE_HISTORY_PAGE_SIZE = 40;
|
const SHARE_HISTORY_PAGE_SIZE = 40;
|
||||||
const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [
|
const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [
|
||||||
{ value: 'always', label: '매번 묻기', minutes: 0 },
|
{ value: 'always', label: '매번 묻기', minutes: 0 },
|
||||||
@@ -191,6 +195,16 @@ type ShareMinimizedProgramItem = {
|
|||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
type ShareMinimizedProgramAction = 'restore' | 'close';
|
||||||
|
type ShareMinimizedProgramActionTracking = {
|
||||||
|
key: string;
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
action: ShareMinimizedProgramAction;
|
||||||
|
captureTarget: HTMLElement;
|
||||||
|
cancelled: boolean;
|
||||||
|
};
|
||||||
type ShareAppEnvironment = PlayAppEnvironment;
|
type ShareAppEnvironment = PlayAppEnvironment;
|
||||||
type ShareSearchResult = {
|
type ShareSearchResult = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -214,11 +228,13 @@ type ShareEdgeGestureTracking =
|
|||||||
| {
|
| {
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
|
startTime: number;
|
||||||
direction: 'back';
|
direction: 'back';
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
|
startTime: number;
|
||||||
direction: 'apps';
|
direction: 'apps';
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
};
|
};
|
||||||
@@ -1512,6 +1528,8 @@ function resolveSharePlayAppInstallThemeColor(appId: string) {
|
|||||||
return '#d97706';
|
return '#d97706';
|
||||||
case 'the-quest':
|
case 'the-quest':
|
||||||
return '#7c3aed';
|
return '#7c3aed';
|
||||||
|
case 'template1':
|
||||||
|
return '#2f8dff';
|
||||||
case 'tetris':
|
case 'tetris':
|
||||||
return '#0f172a';
|
return '#0f172a';
|
||||||
default:
|
default:
|
||||||
@@ -1618,6 +1636,10 @@ function renderEmbeddedSharePlayApp(appId: string | undefined, onBack: () => voi
|
|||||||
return <TheQuestAppView onBack={onBack} launchContext="embedded" />;
|
return <TheQuestAppView onBack={onBack} launchContext="embedded" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appId === 'template1') {
|
||||||
|
return <Template1PlayAppView onBack={onBack} launchContext="embedded" />;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3968,7 +3990,7 @@ function ShareRequestCard({
|
|||||||
danger
|
danger
|
||||||
className="chat-share-page__message-action-button"
|
className="chat-share-page__message-action-button"
|
||||||
loading={isRequestCancellationSaving}
|
loading={isRequestCancellationSaving}
|
||||||
icon={<CloseOutlined />}
|
icon={<ShareCloseIcon />}
|
||||||
aria-label="취소 처리"
|
aria-label="취소 처리"
|
||||||
title="취소 처리"
|
title="취소 처리"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -3997,7 +4019,7 @@ function ShareRequestCard({
|
|||||||
danger
|
danger
|
||||||
className="chat-share-page__message-action-button"
|
className="chat-share-page__message-action-button"
|
||||||
loading={isActiveRequestCancellationSaving}
|
loading={isActiveRequestCancellationSaving}
|
||||||
icon={<CloseOutlined />}
|
icon={<ShareCloseIcon />}
|
||||||
aria-label="취소"
|
aria-label="취소"
|
||||||
title="취소"
|
title="취소"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -4224,6 +4246,7 @@ export function ChatSharePage() {
|
|||||||
const requestedRoomSessionIdRef = useRef(requestedRoomSessionId);
|
const requestedRoomSessionIdRef = useRef(requestedRoomSessionId);
|
||||||
const skipNextRequestedRoomRefreshRef = useRef(false);
|
const skipNextRequestedRoomRefreshRef = useRef(false);
|
||||||
const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null);
|
const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null);
|
||||||
|
const [hasLoadedShareData, setHasLoadedShareData] = useState(initialHasCachedSnapshot);
|
||||||
const [, setIsRefreshing] = useState(false);
|
const [, setIsRefreshing] = useState(false);
|
||||||
const [isLoadingFullSnapshot, setIsLoadingFullSnapshot] = useState(false);
|
const [isLoadingFullSnapshot, setIsLoadingFullSnapshot] = useState(false);
|
||||||
const [isLoadingOlderShareHistory, setIsLoadingOlderShareHistory] = useState(false);
|
const [isLoadingOlderShareHistory, setIsLoadingOlderShareHistory] = useState(false);
|
||||||
@@ -4344,6 +4367,8 @@ export function ChatSharePage() {
|
|||||||
lastY: number;
|
lastY: number;
|
||||||
captureTarget: HTMLDivElement;
|
captureTarget: HTMLDivElement;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const programMinimizedActionTrackingRef = useRef<ShareMinimizedProgramActionTracking | null>(null);
|
||||||
|
const programMinimizedActionClickIgnoreUntilRef = useRef(0);
|
||||||
const programMinimizedMovedRef = useRef(false);
|
const programMinimizedMovedRef = useRef(false);
|
||||||
const minimizedProgramsRef = useRef<ShareMinimizedProgramItem[]>([]);
|
const minimizedProgramsRef = useRef<ShareMinimizedProgramItem[]>([]);
|
||||||
const minimizedProgramPositionByKeyRef = useRef<Record<string, ShareMinimizedProgramItem['position']>>({});
|
const minimizedProgramPositionByKeyRef = useRef<Record<string, ShareMinimizedProgramItem['position']>>({});
|
||||||
@@ -5156,6 +5181,7 @@ export function ChatSharePage() {
|
|||||||
snapshotRefreshInFlightRoomSessionIdRef.current = '';
|
snapshotRefreshInFlightRoomSessionIdRef.current = '';
|
||||||
snapshotRefreshInFlightViewRef.current = null;
|
snapshotRefreshInFlightViewRef.current = null;
|
||||||
}
|
}
|
||||||
|
setHasLoadedShareData(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
|
|
||||||
@@ -5739,6 +5765,19 @@ export function ChatSharePage() {
|
|||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
const dragState = programMinimizedDragStateRef.current;
|
const dragState = programMinimizedDragStateRef.current;
|
||||||
|
const actionTracking = programMinimizedActionTrackingRef.current;
|
||||||
|
|
||||||
|
if (actionTracking && actionTracking.pointerId === event.pointerId) {
|
||||||
|
const deltaX = Math.abs(event.clientX - actionTracking.startX);
|
||||||
|
const deltaY = Math.abs(event.clientY - actionTracking.startY);
|
||||||
|
|
||||||
|
if (Math.max(deltaX, deltaY) > SHARE_MINIMIZED_ACTION_TAP_TOLERANCE_PX) {
|
||||||
|
actionTracking.cancelled = true;
|
||||||
|
if (actionTracking.captureTarget.hasPointerCapture(actionTracking.pointerId)) {
|
||||||
|
actionTracking.captureTarget.releasePointerCapture(actionTracking.pointerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||||||
return;
|
return;
|
||||||
@@ -5768,8 +5807,12 @@ export function ChatSharePage() {
|
|||||||
|
|
||||||
const finishPointerDrag = (event: PointerEvent) => {
|
const finishPointerDrag = (event: PointerEvent) => {
|
||||||
const dragState = programMinimizedDragStateRef.current;
|
const dragState = programMinimizedDragStateRef.current;
|
||||||
|
const actionTracking = programMinimizedActionTrackingRef.current;
|
||||||
|
|
||||||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||||||
|
if (actionTracking?.pointerId === event.pointerId) {
|
||||||
|
clearProgramMinimizedActionTracking();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5778,6 +5821,9 @@ export function ChatSharePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
programMinimizedDragStateRef.current = null;
|
programMinimizedDragStateRef.current = null;
|
||||||
|
if (actionTracking?.pointerId === event.pointerId) {
|
||||||
|
clearProgramMinimizedActionTracking();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
@@ -5791,6 +5837,7 @@ export function ChatSharePage() {
|
|||||||
window.removeEventListener('pointermove', handlePointerMove);
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
window.removeEventListener('pointerup', finishPointerDrag);
|
window.removeEventListener('pointerup', finishPointerDrag);
|
||||||
window.removeEventListener('pointercancel', finishPointerDrag);
|
window.removeEventListener('pointercancel', finishPointerDrag);
|
||||||
|
clearProgramMinimizedActionTracking();
|
||||||
};
|
};
|
||||||
}, [minimizedPrograms.length]);
|
}, [minimizedPrograms.length]);
|
||||||
|
|
||||||
@@ -6002,11 +6049,42 @@ export function ChatSharePage() {
|
|||||||
programMinimizedMovedRef.current = false;
|
programMinimizedMovedRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleProgramMinimizedActionPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
const clearProgramMinimizedActionTracking = useCallback(() => {
|
||||||
|
const actionTracking = programMinimizedActionTrackingRef.current;
|
||||||
|
|
||||||
|
if (actionTracking?.captureTarget.hasPointerCapture(actionTracking.pointerId)) {
|
||||||
|
actionTracking.captureTarget.releasePointerCapture(actionTracking.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
programMinimizedActionTrackingRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleProgramMinimizedActionPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>, action: ShareMinimizedProgramAction, targetKey: string) => {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTargetKey = targetKey.trim();
|
||||||
|
if (!normalizedTargetKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearProgramMinimizedDragState();
|
||||||
|
clearProgramMinimizedActionTracking();
|
||||||
|
programMinimizedActionClickIgnoreUntilRef.current = 0;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
clearProgramMinimizedDragState();
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
}, [clearProgramMinimizedDragState]);
|
programMinimizedActionTrackingRef.current = {
|
||||||
|
key: normalizedTargetKey,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
action,
|
||||||
|
captureTarget: event.currentTarget,
|
||||||
|
cancelled: false,
|
||||||
|
};
|
||||||
|
}, [clearProgramMinimizedDragState, clearProgramMinimizedActionTracking]);
|
||||||
|
|
||||||
const runProgramMinimizedActionAfterPointerCycle = useCallback((action: () => void) => {
|
const runProgramMinimizedActionAfterPointerCycle = useCallback((action: () => void) => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -6067,6 +6145,66 @@ export function ChatSharePage() {
|
|||||||
setMinimizedPrograms((current) => current.filter((item) => item.target.key !== targetKey));
|
setMinimizedPrograms((current) => current.filter((item) => item.target.key !== targetKey));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const runProgramMinimizedAction = useCallback((targetKey: string, action: ShareMinimizedProgramAction) => {
|
||||||
|
if (action === 'restore') {
|
||||||
|
runProgramMinimizedActionAfterPointerCycle(() => {
|
||||||
|
handleRestoreProgram(targetKey);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runProgramMinimizedActionAfterPointerCycle(() => {
|
||||||
|
handleCloseMinimizedProgram(targetKey);
|
||||||
|
});
|
||||||
|
}, [handleRestoreProgram, handleCloseMinimizedProgram, runProgramMinimizedActionAfterPointerCycle]);
|
||||||
|
|
||||||
|
const handleProgramMinimizedActionPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>, action: ShareMinimizedProgramAction, targetKey: string) => {
|
||||||
|
const normalizedTargetKey = targetKey.trim();
|
||||||
|
const tracking = programMinimizedActionTrackingRef.current;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!tracking
|
||||||
|
|| tracking.pointerId !== event.pointerId
|
||||||
|
|| tracking.key !== normalizedTargetKey
|
||||||
|
|| tracking.action !== action
|
||||||
|
|| tracking.cancelled
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const now = typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now();
|
||||||
|
programMinimizedActionClickIgnoreUntilRef.current = now + 500;
|
||||||
|
clearProgramMinimizedActionTracking();
|
||||||
|
runProgramMinimizedAction(normalizedTargetKey, action);
|
||||||
|
}, [runProgramMinimizedAction]);
|
||||||
|
|
||||||
|
const handleProgramMinimizedActionClick = useCallback((event: ReactMouseEvent<HTMLElement>, action: ShareMinimizedProgramAction, targetKey: string) => {
|
||||||
|
if (programMinimizedActionTrackingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now();
|
||||||
|
if (programMinimizedActionClickIgnoreUntilRef.current > now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
runProgramMinimizedAction(targetKey.trim(), action);
|
||||||
|
}, [runProgramMinimizedAction]);
|
||||||
|
|
||||||
|
const handleProgramMinimizedActionKeyDown = useCallback((event: KeyboardEvent<HTMLElement>, action: ShareMinimizedProgramAction, targetKey: string) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
runProgramMinimizedAction(targetKey.trim(), action);
|
||||||
|
}, [runProgramMinimizedAction]);
|
||||||
|
|
||||||
const minimizedProgramCards = minimizedPrograms.map((item, index) => (
|
const minimizedProgramCards = minimizedPrograms.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.target.key}
|
key={item.target.key}
|
||||||
@@ -6098,13 +6236,14 @@ export function ChatSharePage() {
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<AppstoreOutlined />}
|
icon={<AppstoreOutlined />}
|
||||||
className="chat-share-page__program-minimized-button"
|
className="chat-share-page__program-minimized-button"
|
||||||
onPointerDown={handleProgramMinimizedActionPointerDown}
|
onPointerDown={(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
handleProgramMinimizedActionPointerDown(event, 'restore', item.target.key);
|
||||||
|
}}
|
||||||
|
onPointerUp={(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
handleProgramMinimizedActionPointerUp(event, 'restore', item.target.key);
|
||||||
|
}}
|
||||||
onClick={(event: ReactMouseEvent<HTMLElement>) => {
|
onClick={(event: ReactMouseEvent<HTMLElement>) => {
|
||||||
event.preventDefault();
|
handleProgramMinimizedActionClick(event, 'restore', item.target.key);
|
||||||
event.stopPropagation();
|
|
||||||
runProgramMinimizedActionAfterPointerCycle(() => {
|
|
||||||
handleRestoreProgram(item.target.key);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
열기
|
열기
|
||||||
@@ -6113,15 +6252,16 @@ export function ChatSharePage() {
|
|||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close"
|
className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close"
|
||||||
icon={<CloseOutlined />}
|
icon={<ShareCloseIcon />}
|
||||||
aria-label="프로그램 닫기"
|
aria-label="프로그램 닫기"
|
||||||
onPointerDown={handleProgramMinimizedActionPointerDown}
|
onPointerDown={(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
handleProgramMinimizedActionPointerDown(event, 'close', item.target.key);
|
||||||
|
}}
|
||||||
|
onPointerUp={(event: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
handleProgramMinimizedActionPointerUp(event, 'close', item.target.key);
|
||||||
|
}}
|
||||||
onClick={(event: ReactMouseEvent<HTMLElement>) => {
|
onClick={(event: ReactMouseEvent<HTMLElement>) => {
|
||||||
event.preventDefault();
|
handleProgramMinimizedActionClick(event, 'close', item.target.key);
|
||||||
event.stopPropagation();
|
|
||||||
runProgramMinimizedActionAfterPointerCycle(() => {
|
|
||||||
handleCloseMinimizedProgram(item.target.key);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -6342,6 +6482,7 @@ export function ChatSharePage() {
|
|||||||
if (!normalizedToken) {
|
if (!normalizedToken) {
|
||||||
setErrorMessage('공유 링크가 없습니다.');
|
setErrorMessage('공유 링크가 없습니다.');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setHasLoadedShareData(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6364,6 +6505,7 @@ export function ChatSharePage() {
|
|||||||
deferredSnapshotRef.current = null;
|
deferredSnapshotRef.current = null;
|
||||||
setSnapshot(cachedSnapshot);
|
setSnapshot(cachedSnapshot);
|
||||||
setIsLoading(cachedSnapshot == null);
|
setIsLoading(cachedSnapshot == null);
|
||||||
|
setHasLoadedShareData(cachedSnapshot != null);
|
||||||
setIsRoomSwitching(false);
|
setIsRoomSwitching(false);
|
||||||
setRequestedRoomSessionId(restoredRoomSessionId);
|
setRequestedRoomSessionId(restoredRoomSessionId);
|
||||||
}, [normalizedToken]);
|
}, [normalizedToken]);
|
||||||
@@ -8209,7 +8351,7 @@ export function ChatSharePage() {
|
|||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
className="chat-share-page__process-inspector-window-button"
|
className="chat-share-page__process-inspector-window-button"
|
||||||
icon={<CloseOutlined />}
|
icon={<ShareCloseIcon />}
|
||||||
aria-label="상세 과정 닫기"
|
aria-label="상세 과정 닫기"
|
||||||
onClick={closeProcessInspector}
|
onClick={closeProcessInspector}
|
||||||
/>
|
/>
|
||||||
@@ -9607,6 +9749,7 @@ export function ChatSharePage() {
|
|||||||
|| Boolean(activeProcessInspectorRequestId);
|
|| Boolean(activeProcessInspectorRequestId);
|
||||||
|
|
||||||
let tracking: ShareEdgeGestureTracking | null = null;
|
let tracking: ShareEdgeGestureTracking | null = null;
|
||||||
|
let lastTouch: Touch | null = null;
|
||||||
|
|
||||||
const resetTracking = () => {
|
const resetTracking = () => {
|
||||||
tracking = null;
|
tracking = null;
|
||||||
@@ -9620,27 +9763,28 @@ export function ChatSharePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const centerBandStart = window.innerHeight * SHARE_APPS_EDGE_MIDDLE_BAND_RATIO;
|
|
||||||
const centerBandEnd = window.innerHeight * (1 - SHARE_APPS_EDGE_MIDDLE_BAND_RATIO);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
touch.clientX >= window.innerWidth - SHARE_EDGE_NAVIGATION_HOTZONE_PX
|
touch.clientX >= window.innerWidth - SHARE_EDGE_APPS_GESTURE_HOTZONE_PX
|
||||||
&& touch.clientY >= centerBandStart
|
|
||||||
&& touch.clientY <= centerBandEnd
|
|
||||||
) {
|
) {
|
||||||
|
const now = performance.now();
|
||||||
|
event.preventDefault();
|
||||||
tracking = {
|
tracking = {
|
||||||
startX: touch.clientX,
|
startX: touch.clientX,
|
||||||
startY: touch.clientY,
|
startY: touch.clientY,
|
||||||
|
startTime: now,
|
||||||
direction: 'apps',
|
direction: 'apps',
|
||||||
opened: false,
|
opened: false,
|
||||||
};
|
};
|
||||||
|
lastTouch = touch;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (touch.clientX <= SHARE_EDGE_NAVIGATION_HOTZONE_PX) {
|
if (touch.clientX <= SHARE_EDGE_NAVIGATION_HOTZONE_PX) {
|
||||||
|
const now = performance.now();
|
||||||
tracking = {
|
tracking = {
|
||||||
startX: touch.clientX,
|
startX: touch.clientX,
|
||||||
startY: touch.clientY,
|
startY: touch.clientY,
|
||||||
|
startTime: now,
|
||||||
direction: 'back',
|
direction: 'back',
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
@@ -9656,41 +9800,71 @@ export function ChatSharePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastTouch = touch;
|
||||||
|
|
||||||
const deltaX = touch.clientX - tracking.startX;
|
const deltaX = touch.clientX - tracking.startX;
|
||||||
const deltaY = touch.clientY - tracking.startY;
|
|
||||||
|
|
||||||
if (Math.abs(deltaY) > SHARE_EDGE_GESTURE_MAX_VERTICAL_DRIFT_PX) {
|
|
||||||
tracking = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tracking.direction === 'back') {
|
|
||||||
if (deltaX >= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (tracking.direction === 'apps') {
|
||||||
|
if (deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
|
||||||
if (deltaX <= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX * -1) {
|
if (deltaX <= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX * -1) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tracking.opened && deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
|
if (!tracking.opened) {
|
||||||
tracking.opened = true;
|
tracking.opened = true;
|
||||||
openShareAppsPanel();
|
openShareAppsPanel();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaX <= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX * -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracking.direction === 'back' && deltaX >= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true, capture: true });
|
const handleTouchEnd = (event: TouchEvent) => {
|
||||||
|
const touch = event.changedTouches[0] ?? lastTouch;
|
||||||
|
|
||||||
|
if (!tracking || !touch) {
|
||||||
|
resetTracking();
|
||||||
|
lastTouch = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracking.direction === 'apps' && !tracking.opened) {
|
||||||
|
const deltaX = touch.clientX - tracking.startX;
|
||||||
|
|
||||||
|
if (deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
|
||||||
|
tracking.opened = true;
|
||||||
|
openShareAppsPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTracking();
|
||||||
|
lastTouch = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchCancel = () => {
|
||||||
|
resetTracking();
|
||||||
|
lastTouch = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: false, capture: true });
|
||||||
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
|
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
|
||||||
scrollContainer.addEventListener('touchend', resetTracking, { passive: true, capture: true });
|
scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true, capture: true });
|
||||||
scrollContainer.addEventListener('touchcancel', resetTracking, { passive: true, capture: true });
|
scrollContainer.addEventListener('touchcancel', handleTouchCancel, { passive: true, capture: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollContainer.removeEventListener('touchstart', handleTouchStart, true);
|
scrollContainer.removeEventListener('touchstart', handleTouchStart, true);
|
||||||
scrollContainer.removeEventListener('touchmove', handleTouchMove, true);
|
scrollContainer.removeEventListener('touchmove', handleTouchMove, true);
|
||||||
scrollContainer.removeEventListener('touchend', resetTracking, true);
|
scrollContainer.removeEventListener('touchend', handleTouchEnd, true);
|
||||||
scrollContainer.removeEventListener('touchcancel', resetTracking, true);
|
scrollContainer.removeEventListener('touchcancel', handleTouchCancel, true);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeProcessInspectorRequestId,
|
activeProcessInspectorRequestId,
|
||||||
@@ -9951,7 +10125,7 @@ export function ChatSharePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessage || !snapshot) {
|
if (errorMessage || (!snapshot && hasLoadedShareData)) {
|
||||||
return (
|
return (
|
||||||
<div className="chat-share-page chat-share-page--centered">
|
<div className="chat-share-page chat-share-page--centered">
|
||||||
<div className="chat-share-page__panel chat-share-page__empty-card">
|
<div className="chat-share-page__panel chat-share-page__empty-card">
|
||||||
@@ -9962,6 +10136,14 @@ export function ChatSharePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return (
|
||||||
|
<div className="chat-share-page chat-share-page--centered">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -10555,7 +10737,7 @@ export function ChatSharePage() {
|
|||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
className="app-chat-panel__composer-attachment-remove"
|
className="app-chat-panel__composer-attachment-remove"
|
||||||
icon={<CloseOutlined />}
|
icon={<ShareCloseIcon />}
|
||||||
aria-label={`${attachment.name} 첨부 제거`}
|
aria-label={`${attachment.name} 첨부 제거`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setComposerAttachments((current) => current.filter((item) => item.id !== attachment.id));
|
setComposerAttachments((current) => current.filter((item) => item.id !== attachment.id));
|
||||||
|
|||||||
@@ -97,6 +97,12 @@
|
|||||||
rgba(255, 255, 255, 0.06);
|
rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apps-library__card--template1 {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(113, 184, 255, 0.24), rgba(96, 138, 255, 0.16)),
|
||||||
|
rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.apps-library__card--the-quest {
|
.apps-library__card--the-quest {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 201, 112, 0.24), rgba(104, 198, 255, 0.14)),
|
linear-gradient(180deg, rgba(255, 201, 112, 0.24), rgba(104, 198, 255, 0.14)),
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { PhotoPrismAppView } from '../photoprism/PhotoPrismAppView';
|
|||||||
import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView';
|
import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView';
|
||||||
import { TheQuestAppView } from '../the-quest/TheQuestAppView';
|
import { TheQuestAppView } from '../the-quest/TheQuestAppView';
|
||||||
import { TetrisAppView } from '../tetris/TetrisAppView';
|
import { TetrisAppView } from '../tetris/TetrisAppView';
|
||||||
import { APP_LIBRARY_ENTRIES, findReadyPlayAppEntryById } from './appsRegistry';
|
import { Template1PlayAppView } from '../template1/Template1PlayAppView';
|
||||||
|
import {
|
||||||
|
getCurrentPlayAppEnvironment,
|
||||||
|
getPlayAppEntries,
|
||||||
|
isPlayAppLaunchableInEnvironment,
|
||||||
|
loadPlayAppEntriesFromServer,
|
||||||
|
} from './appsRegistry';
|
||||||
import { buildPlayAppPath } from '../../../../app/main/routes';
|
import { buildPlayAppPath } from '../../../../app/main/routes';
|
||||||
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../../../../app/main/pwa/installManifest';
|
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../../../../app/main/pwa/installManifest';
|
||||||
|
|
||||||
@@ -30,6 +36,8 @@ function resolvePlayAppInstallThemeColor(appId: string) {
|
|||||||
return '#d97706';
|
return '#d97706';
|
||||||
case 'the-quest':
|
case 'the-quest':
|
||||||
return '#7c3aed';
|
return '#7c3aed';
|
||||||
|
case 'template1':
|
||||||
|
return '#2f8dff';
|
||||||
case 'tetris':
|
case 'tetris':
|
||||||
return '#0f172a';
|
return '#0f172a';
|
||||||
default:
|
default:
|
||||||
@@ -44,12 +52,47 @@ export function AppsLibraryView() {
|
|||||||
const [isCompactViewport, setIsCompactViewport] = useState(() =>
|
const [isCompactViewport, setIsCompactViewport] = useState(() =>
|
||||||
typeof window === 'undefined' ? false : window.matchMedia('(max-width: 768px)').matches,
|
typeof window === 'undefined' ? false : window.matchMedia('(max-width: 768px)').matches,
|
||||||
);
|
);
|
||||||
|
const [playAppEntries, setPlayAppEntries] = useState(() => getPlayAppEntries());
|
||||||
|
const [isLoadingPlayApps, setIsLoadingPlayApps] = useState(false);
|
||||||
const activeAppId = searchParams.get('app');
|
const activeAppId = searchParams.get('app');
|
||||||
const launchContext = searchParams.get('launchContext') === 'embedded' ? 'embedded' : 'direct';
|
const launchContext = searchParams.get('launchContext') === 'embedded' ? 'embedded' : 'direct';
|
||||||
const returnTo = normalizeReturnToPath(searchParams.get('returnTo'));
|
const returnTo = normalizeReturnToPath(searchParams.get('returnTo'));
|
||||||
const activeAppEntry = findReadyPlayAppEntryById(activeAppId);
|
const environment = getCurrentPlayAppEnvironment();
|
||||||
|
const activeAppEntry = useMemo(() => {
|
||||||
|
const candidate = playAppEntries.find((entry) => entry.id === activeAppId);
|
||||||
|
if (!candidate || !isPlayAppLaunchableInEnvironment(candidate, environment)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const readyCount = useMemo(() => APP_LIBRARY_ENTRIES.filter((entry) => entry.isReady).length, []);
|
return candidate;
|
||||||
|
}, [activeAppId, environment, playAppEntries]);
|
||||||
|
|
||||||
|
const readyCount = useMemo(
|
||||||
|
() => playAppEntries.filter((entry) => isPlayAppLaunchableInEnvironment(entry, environment)).length,
|
||||||
|
[environment, playAppEntries],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isDisposed = false;
|
||||||
|
setIsLoadingPlayApps(true);
|
||||||
|
|
||||||
|
void loadPlayAppEntriesFromServer()
|
||||||
|
.then((entries) => {
|
||||||
|
if (!isDisposed) {
|
||||||
|
setPlayAppEntries(entries);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
if (!isDisposed) {
|
||||||
|
setIsLoadingPlayApps(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isDisposed = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -140,25 +183,31 @@ export function AppsLibraryView() {
|
|||||||
return <TheQuestAppView onBack={closeApp} launchContext={launchContext} />;
|
return <TheQuestAppView onBack={closeApp} launchContext={launchContext} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeAppEntry?.id === 'template1') {
|
||||||
|
return <Template1PlayAppView onBack={closeApp} launchContext={launchContext} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="apps-library" data-testid="apps-library">
|
<section className="apps-library" data-testid="apps-library">
|
||||||
<header className="apps-library__topbar">
|
<header className="apps-library__topbar">
|
||||||
<div className="apps-library__title">
|
<div className="apps-library__title">
|
||||||
<strong>앱 보관함</strong>
|
<strong>앱 보관함</strong>
|
||||||
<span>{APP_LIBRARY_ENTRIES.length}개</span>
|
<span>{playAppEntries.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
<Tag bordered={false} color="gold">
|
<Tag bordered={false} color="gold">
|
||||||
실행 가능 {readyCount}
|
{isLoadingPlayApps ? '실행 가능 로딩중' : `실행 가능 ${readyCount}`}
|
||||||
</Tag>
|
</Tag>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className={`apps-library__shelf${isCompactViewport ? ' apps-library__shelf--compact' : ''}`}>
|
<div className={`apps-library__shelf${isCompactViewport ? ' apps-library__shelf--compact' : ''}`}>
|
||||||
{APP_LIBRARY_ENTRIES.map((entry) => (
|
{playAppEntries.map((entry) => (
|
||||||
<button
|
<button
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`apps-library__card ${entry.accentClassName}${entry.isReady ? ' apps-library__card--ready' : ''}`}
|
className={`apps-library__card ${
|
||||||
disabled={!entry.isReady}
|
entry.accentClassName
|
||||||
|
}${isPlayAppLaunchableInEnvironment(entry, environment) ? ' apps-library__card--ready' : ''}`}
|
||||||
|
disabled={!isPlayAppLaunchableInEnvironment(entry, environment)}
|
||||||
data-testid={
|
data-testid={
|
||||||
entry.id === 'e-reader'
|
entry.id === 'e-reader'
|
||||||
? 'apps-library-open-e-reader'
|
? 'apps-library-open-e-reader'
|
||||||
@@ -172,6 +221,8 @@ export function AppsLibraryView() {
|
|||||||
? 'apps-library-open-tetris'
|
? 'apps-library-open-tetris'
|
||||||
: entry.id === 'the-quest'
|
: entry.id === 'the-quest'
|
||||||
? 'apps-library-open-the-quest'
|
? 'apps-library-open-the-quest'
|
||||||
|
: entry.id === 'template1'
|
||||||
|
? 'apps-library-open-template1'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={() => openApp(entry.id)}
|
onClick={() => openApp(entry.id)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
AppstoreAddOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
@@ -15,6 +16,19 @@ import type { ReactNode } from 'react';
|
|||||||
|
|
||||||
export type PlayAppEnvironment = 'preview' | 'test' | 'prod';
|
export type PlayAppEnvironment = 'preview' | 'test' | 'prod';
|
||||||
|
|
||||||
|
type PlayAppServerRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accentClassName: string;
|
||||||
|
statusLabel: string;
|
||||||
|
isReady: boolean;
|
||||||
|
iconName: string;
|
||||||
|
usagePriority?: number;
|
||||||
|
supportedEnvironments?: PlayAppEnvironment[];
|
||||||
|
searchKeywords?: string[];
|
||||||
|
searchDescription?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlayAppEntry = {
|
export type PlayAppEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,7 +42,25 @@ export type PlayAppEntry = {
|
|||||||
searchDescription?: string;
|
searchDescription?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
const PLAY_APP_ICON_REGISTRY: Record<string, ReactNode> = {
|
||||||
|
AppstoreOutlined: <AppstoreOutlined />,
|
||||||
|
AppstoreAddOutlined: <AppstoreAddOutlined />,
|
||||||
|
BellOutlined: <BellOutlined />,
|
||||||
|
BookOutlined: <BookOutlined />,
|
||||||
|
FireOutlined: <FireOutlined />,
|
||||||
|
FundProjectionScreenOutlined: <FundProjectionScreenOutlined />,
|
||||||
|
FileImageOutlined: <FileImageOutlined />,
|
||||||
|
PictureOutlined: <PictureOutlined />,
|
||||||
|
RocketOutlined: <RocketOutlined />,
|
||||||
|
SoundOutlined: <SoundOutlined />,
|
||||||
|
StarOutlined: <StarOutlined />,
|
||||||
|
ThunderboltOutlined: <ThunderboltOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAY_APP_API_PATH = '/api/play-apps';
|
||||||
|
const FALLBACK_ICON = <AppstoreOutlined />;
|
||||||
|
|
||||||
|
const FALLBACK_ENTRIES: PlayAppEntry[] = [
|
||||||
{
|
{
|
||||||
id: 'baseball-ticket-bay',
|
id: 'baseball-ticket-bay',
|
||||||
name: '야구-티켓베이',
|
name: '야구-티켓베이',
|
||||||
@@ -89,6 +121,18 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
|||||||
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
|
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
|
||||||
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
|
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'template1',
|
||||||
|
name: 'Template1',
|
||||||
|
accentClassName: 'apps-library__card--template1',
|
||||||
|
statusLabel: '템플릿',
|
||||||
|
isReady: true,
|
||||||
|
icon: <AppstoreAddOutlined />,
|
||||||
|
usagePriority: 45,
|
||||||
|
supportedEnvironments: ['preview', 'test'],
|
||||||
|
searchKeywords: ['template1', 'template', '앱 템플릿', '레이아웃', '기본 UI', 'layout'],
|
||||||
|
searchDescription: '다른 앱 개발 시 공통 레이아웃을 빠르게 적용하기 위한 템플릿 화면입니다.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'tetris',
|
id: 'tetris',
|
||||||
name: 'Tetris',
|
name: 'Tetris',
|
||||||
@@ -108,8 +152,123 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
|||||||
{ id: 'app-vault', name: 'App Vault', accentClassName: 'apps-library__card--vault', statusLabel: '테마', isReady: false, icon: <AppstoreOutlined /> },
|
{ id: 'app-vault', name: 'App Vault', accentClassName: 'apps-library__card--vault', statusLabel: '테마', isReady: false, icon: <AppstoreOutlined /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getReadyPlayAppEntries() {
|
export const APP_LIBRARY_ENTRIES = FALLBACK_ENTRIES;
|
||||||
return APP_LIBRARY_ENTRIES.filter((entry) => entry.isReady);
|
|
||||||
|
let playAppCache = FALLBACK_ENTRIES;
|
||||||
|
let loadingPromise: Promise<PlayAppEntry[]> | null = null;
|
||||||
|
|
||||||
|
function isObjectValue(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSearchKeywords(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlayEnvironment(value: unknown): value is PlayAppEnvironment {
|
||||||
|
return value === 'preview' || value === 'test' || value === 'prod';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlayAppServerPayload(payload: unknown): PlayAppServerRow[] {
|
||||||
|
if (!isObjectValue(payload) || !Array.isArray(payload.items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.items
|
||||||
|
.map((raw) => {
|
||||||
|
if (!isObjectValue(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
||||||
|
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
||||||
|
const accentClassName = typeof raw.accentClassName === 'string' ? raw.accentClassName.trim() : '';
|
||||||
|
const statusLabel = typeof raw.statusLabel === 'string' ? raw.statusLabel.trim() : '';
|
||||||
|
const iconName = typeof raw.iconName === 'string' ? raw.iconName.trim() : '';
|
||||||
|
|
||||||
|
if (!id || !name || !accentClassName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSupported = Array.isArray(raw.supportedEnvironments) ? raw.supportedEnvironments : [];
|
||||||
|
const supportedEnvironments = rawSupported.filter(isPlayEnvironment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
accentClassName,
|
||||||
|
statusLabel: statusLabel || '준비',
|
||||||
|
isReady: raw.isReady === true,
|
||||||
|
iconName,
|
||||||
|
usagePriority: typeof raw.usagePriority === 'number' ? raw.usagePriority : undefined,
|
||||||
|
supportedEnvironments,
|
||||||
|
searchKeywords: parseSearchKeywords(raw.searchKeywords),
|
||||||
|
searchDescription: typeof raw.searchDescription === 'string' ? raw.searchDescription.trim() : '',
|
||||||
|
} satisfies PlayAppServerRow;
|
||||||
|
})
|
||||||
|
.filter((item): item is PlayAppServerRow => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPlayAppEntriesByPriority(entries: PlayAppEntry[]) {
|
||||||
|
return entries.slice().sort((lhs, rhs) => {
|
||||||
|
const lhsPriority = lhs.usagePriority ?? 0;
|
||||||
|
const rhsPriority = rhs.usagePriority ?? 0;
|
||||||
|
|
||||||
|
if (rhsPriority !== lhsPriority) {
|
||||||
|
return rhsPriority - lhsPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhs.id.localeCompare(rhs.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSupportedEnvironments(rawEnvironments: PlayAppEnvironment[] | undefined) {
|
||||||
|
if (!rawEnvironments || rawEnvironments.length === 0) {
|
||||||
|
return ['preview'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawEnvironments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryWithIcon(entry: PlayAppServerRow): PlayAppEntry {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
accentClassName: entry.accentClassName,
|
||||||
|
statusLabel: entry.statusLabel,
|
||||||
|
isReady: entry.isReady,
|
||||||
|
icon: PLAY_APP_ICON_REGISTRY[entry.iconName] ?? FALLBACK_ICON,
|
||||||
|
usagePriority: entry.usagePriority,
|
||||||
|
supportedEnvironments: normalizeSupportedEnvironments(entry.supportedEnvironments),
|
||||||
|
searchKeywords: entry.searchKeywords,
|
||||||
|
searchDescription: entry.searchDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPlayAppEnvironment(): PlayAppEnvironment {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'prod';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const isLocalHost =
|
||||||
|
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0';
|
||||||
|
|
||||||
|
if (isLocalHost) {
|
||||||
|
return 'test';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname.includes('preview')) {
|
||||||
|
return 'preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'prod';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSupportedPlayAppEnvironments(entry: PlayAppEntry): PlayAppEnvironment[] {
|
export function getSupportedPlayAppEnvironments(entry: PlayAppEntry): PlayAppEnvironment[] {
|
||||||
@@ -124,10 +283,69 @@ export function isPlayAppSupportedInEnvironment(entry: PlayAppEntry, environment
|
|||||||
return getSupportedPlayAppEnvironments(entry).includes(environment);
|
return getSupportedPlayAppEnvironments(entry).includes(environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPlayAppLaunchableInEnvironment(
|
||||||
|
entry: PlayAppEntry,
|
||||||
|
environment: PlayAppEnvironment = getCurrentPlayAppEnvironment(),
|
||||||
|
) {
|
||||||
|
if (!isPlayAppSupportedInEnvironment(entry, environment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (environment === 'preview') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.isReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlayAppEntries() {
|
||||||
|
return playAppCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReadyPlayAppEntries() {
|
||||||
|
const environment = getCurrentPlayAppEnvironment();
|
||||||
|
return playAppCache.filter((entry) => isPlayAppLaunchableInEnvironment(entry, environment));
|
||||||
|
}
|
||||||
|
|
||||||
export function findReadyPlayAppEntryById(appId: string | null | undefined) {
|
export function findReadyPlayAppEntryById(appId: string | null | undefined) {
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getReadyPlayAppEntries().find((entry) => entry.id === appId) ?? null;
|
const entry = playAppCache.find((candidate) => candidate.id === appId);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPlayAppLaunchableInEnvironment(entry) ? entry : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPlayAppEntriesFromServer() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return Promise.resolve(playAppCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loadingPromise) {
|
||||||
|
loadingPromise = fetch(PLAY_APP_API_PATH)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`앱 목록 조회 실패: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const normalizedRows = normalizePlayAppServerPayload(payload).map(getEntryWithIcon);
|
||||||
|
playAppCache = sortPlayAppEntriesByPriority(normalizedRows.length ? normalizedRows : FALLBACK_ENTRIES);
|
||||||
|
|
||||||
|
return playAppCache;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
playAppCache = FALLBACK_ENTRIES;
|
||||||
|
return playAppCache;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loadingPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadingPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1004,15 +1004,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.e-reader__book-card strong {
|
.e-reader__book-card strong {
|
||||||
display: -webkit-box;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.42;
|
line-height: 1.42;
|
||||||
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow-wrap: anywhere;
|
min-width: 0;
|
||||||
word-break: keep-all;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-reader__book-card p,
|
.e-reader__book-card p,
|
||||||
|
|||||||
398
src/views/play/apps/template1/Template1PlayAppView.css
Normal file
398
src/views/play/apps/template1/Template1PlayAppView.css
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
.template1-app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 22px;
|
||||||
|
color: #0f172a;
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #f4f7fe 100%);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__topbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 0;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96) 0%, rgba(248, 250, 252, 0.75) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__badge {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #0c4a9e;
|
||||||
|
background: #dbeafe;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.16);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__badge:focus-visible {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__badge:hover {
|
||||||
|
box-shadow: 0 1px 8px rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__badge:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__badge .anticon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(18px, 2vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__brand p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__topbar-action {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.16);
|
||||||
|
color: #0c4a9e;
|
||||||
|
background: #dbeafe;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__topbar-action:hover {
|
||||||
|
box-shadow: 0 1px 8px rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__topbar-action:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__topbar-action .anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 58px;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 208px;
|
||||||
|
width: min(248px, calc(100% - 28px));
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #ffffff;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__main {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__scroll-area {
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
padding-right: 2px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__screen-transition {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__section-head p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__chips-text {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__section-description {
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__section-head--detail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__section-head--detail small {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__home-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__home-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
min-height: 96px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.88) 100%);
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__home-item div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__home-item strong {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__home-item span,
|
||||||
|
.template1-app__home-item small {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__home-item small {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__detail-shell {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
min-height: 0;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #dbe4f2;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-item--danger {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: linear-gradient(180deg, #fff1f2 0%, #fff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-item-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-item-content strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-item-content span {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-item-description--muted {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__settings-item-icon--danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 84px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #dbe4f2;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__card div strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__card div span {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__card-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__card-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #3b82f6;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__title-row h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.template1-app__cards {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.template1-app {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 16px;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__brand h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__brand p {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__cards,
|
||||||
|
.template1-app__home-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__home-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__section-head--detail {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__card {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template1-app__card-action {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
434
src/views/play/apps/template1/Template1PlayAppView.tsx
Normal file
434
src/views/play/apps/template1/Template1PlayAppView.tsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
import {
|
||||||
|
AppstoreOutlined,
|
||||||
|
AppstoreAddOutlined,
|
||||||
|
BellOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CompassOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import './Template1PlayAppView.css';
|
||||||
|
|
||||||
|
type Template1PlayAppViewProps = {
|
||||||
|
onBack: () => void;
|
||||||
|
launchContext?: 'direct' | 'embedded';
|
||||||
|
};
|
||||||
|
|
||||||
|
type Template1SectionId = 'home' | 'projects' | 'resources' | 'automation';
|
||||||
|
|
||||||
|
type Template1Section = {
|
||||||
|
id: Template1SectionId;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
chips: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Template1MenuItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
screen?: Template1SectionId;
|
||||||
|
actionLabel?: string;
|
||||||
|
actionType?: 'navigate' | 'exit' | 'local';
|
||||||
|
settingAction?: 'theme' | 'notifications' | 'reset-state';
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEMPLATE1_SECTIONS: Template1Section[] = [
|
||||||
|
{
|
||||||
|
id: 'home',
|
||||||
|
label: '홈',
|
||||||
|
description: '빠른 네비게이션으로 주요 기능으로 이동',
|
||||||
|
chips: ['요약', '최근 작업', '바로가기'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'projects',
|
||||||
|
label: '작업',
|
||||||
|
description: '프로젝트 관리나 문맥 전환 진입점',
|
||||||
|
chips: ['레이아웃', '요청', '검수'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resources',
|
||||||
|
label: '리소스',
|
||||||
|
description: '문서/데이터/관리 화면 모음',
|
||||||
|
chips: ['문서', '리소스', '로그'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'automation',
|
||||||
|
label: '자동화',
|
||||||
|
description: '자동화와 상태 확인 흐름',
|
||||||
|
chips: ['플랜', '체크리스트', '실행 이력'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEMPLATE1_MENUS: Record<Template1SectionId, Template1MenuItem[]> = {
|
||||||
|
home: [
|
||||||
|
{
|
||||||
|
title: '오늘 개요',
|
||||||
|
description: '최근 작업 수와 진행 상태를 한 화면에서 확인',
|
||||||
|
icon: <CompassOutlined />,
|
||||||
|
screen: 'home',
|
||||||
|
actionLabel: '열기',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '요청 보드',
|
||||||
|
description: '채팅/플랜 타입의 빠른 이동',
|
||||||
|
icon: <ThunderboltOutlined />,
|
||||||
|
screen: 'projects',
|
||||||
|
actionLabel: '이동',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '앱 바코드',
|
||||||
|
description: '새 앱 연결 전에 기본 레이아웃 점검',
|
||||||
|
icon: <AppstoreAddOutlined />,
|
||||||
|
screen: 'projects',
|
||||||
|
actionLabel: '확인',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
title: 'Layout Editor',
|
||||||
|
description: '레이아웃 편집 흐름 컴포넌트',
|
||||||
|
icon: <CompassOutlined />,
|
||||||
|
screen: 'projects',
|
||||||
|
actionLabel: '열기',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Layout Draw',
|
||||||
|
description: '컴포넌트 샘플 배치 화면',
|
||||||
|
icon: <ThunderboltOutlined />,
|
||||||
|
screen: 'projects',
|
||||||
|
actionLabel: '열기',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '저장된 레이아웃',
|
||||||
|
description: '저장 기록과 재열기 진입점',
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
screen: 'projects',
|
||||||
|
actionLabel: '열기',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
title: '문서',
|
||||||
|
description: '문서 목록과 상세 진입 구성',
|
||||||
|
icon: <CompassOutlined />,
|
||||||
|
screen: 'resources',
|
||||||
|
actionLabel: '열기',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '리소스 관리',
|
||||||
|
description: '공유 리소스 목록과 승인 상태',
|
||||||
|
icon: <AppstoreAddOutlined />,
|
||||||
|
screen: 'resources',
|
||||||
|
actionLabel: '열기',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '활동 로그',
|
||||||
|
description: '최근 변경 이력 요약',
|
||||||
|
icon: <BellOutlined />,
|
||||||
|
screen: 'automation',
|
||||||
|
actionLabel: '열기',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
automation: [
|
||||||
|
{
|
||||||
|
title: '자동화',
|
||||||
|
description: 'Plan/작업 진행 목록',
|
||||||
|
icon: <AppstoreAddOutlined />,
|
||||||
|
screen: 'automation',
|
||||||
|
actionLabel: '열기',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '체크리스트',
|
||||||
|
description: '요청 단계 추적',
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
screen: 'automation',
|
||||||
|
actionLabel: '확인',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '에러 로그',
|
||||||
|
description: '실패/경고 대응 화면',
|
||||||
|
icon: <CloseOutlined />,
|
||||||
|
screen: 'automation',
|
||||||
|
actionLabel: '확인',
|
||||||
|
actionType: 'navigate',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsItems: Template1MenuItem[] = [
|
||||||
|
{
|
||||||
|
title: '테마',
|
||||||
|
description: '배경/카드 스타일 상태를 확인',
|
||||||
|
icon: <AppstoreOutlined />,
|
||||||
|
actionType: 'local',
|
||||||
|
settingAction: 'theme',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '알림',
|
||||||
|
description: '알림 영역 설정 진입점',
|
||||||
|
icon: <BellOutlined />,
|
||||||
|
actionType: 'local',
|
||||||
|
settingAction: 'notifications',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '상태 초기화',
|
||||||
|
description: '현재 화면을 홈 상태로 복원',
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
actionType: 'local',
|
||||||
|
settingAction: 'reset-state',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '앱 종료',
|
||||||
|
description: '설정을 닫고 앱 화면을 종료합니다.',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
actionType: 'exit',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Template1HomeScreenProps = {
|
||||||
|
items: Pick<Template1MenuItem, 'title' | 'description' | 'icon' | 'actionLabel'> & {
|
||||||
|
chips: string[];
|
||||||
|
screen: Template1SectionId;
|
||||||
|
}[];
|
||||||
|
onItemSelect: (item: Template1MenuItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Template1SectionScreenProps = {
|
||||||
|
title: string;
|
||||||
|
chips: string[];
|
||||||
|
cards: Template1MenuItem[];
|
||||||
|
onItemSelect: (item: Template1MenuItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Template1HomeScreen({ items, onItemSelect }: Template1HomeScreenProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="template1-app__section-head">
|
||||||
|
<h2>콘텐츠 목록</h2>
|
||||||
|
</header>
|
||||||
|
<div className="template1-app__home-list" aria-label="콘텐츠 목록">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.title}
|
||||||
|
className="template1-app__home-item"
|
||||||
|
onClick={() => onItemSelect({ ...item, screen: item.screen, actionType: 'navigate' })}
|
||||||
|
>
|
||||||
|
<span className="template1-app__card-icon">{item.icon}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span>{item.description}</span>
|
||||||
|
<small>{item.chips.join(' · ')}</small>
|
||||||
|
</div>
|
||||||
|
<span className="template1-app__card-action">{item.actionLabel ?? '열기'}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Template1SectionScreen({ title, chips, cards, onItemSelect }: Template1SectionScreenProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="template1-app__chips-text">{chips.join(' · ')}</div>
|
||||||
|
<div className="template1-app__cards" aria-label={title}>
|
||||||
|
<div className="template1-app__title-row">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
{cards.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.title}
|
||||||
|
className="template1-app__card"
|
||||||
|
onClick={() => onItemSelect(item)}
|
||||||
|
>
|
||||||
|
<span className="template1-app__card-icon">{item.icon}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span>{item.description}</span>
|
||||||
|
</div>
|
||||||
|
<span className="template1-app__card-action">{item.actionLabel ?? '열기'}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Template1SettingsDropdown({
|
||||||
|
onItemSelect,
|
||||||
|
}: {
|
||||||
|
onItemSelect: (item: Template1MenuItem) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="template1-app__settings-dropdown" role="menu" aria-label="설정 메뉴">
|
||||||
|
<div className="template1-app__settings-list">
|
||||||
|
{settingsItems.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.title}
|
||||||
|
className={`template1-app__settings-item${item.actionType === 'exit' ? ' template1-app__settings-item--danger' : ''}`}
|
||||||
|
onClick={() => onItemSelect(item)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`template1-app__card-icon template1-app__settings-item-icon${item.actionType === 'exit' ? ' template1-app__settings-item-icon--danger' : ''}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<div className="template1-app__settings-item-content">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span
|
||||||
|
className={item.actionType === 'exit' ? 'template1-app__settings-item-description--muted' : undefined}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Template1PlayAppView({ onBack, launchContext = 'direct' }: Template1PlayAppViewProps) {
|
||||||
|
const [activeSection, setActiveSection] = useState<Template1SectionId>('home');
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
void launchContext;
|
||||||
|
|
||||||
|
const activeSectionItem = useMemo(
|
||||||
|
() => TEMPLATE1_SECTIONS.find((item) => item.id === activeSection) ?? TEMPLATE1_SECTIONS[0],
|
||||||
|
[activeSection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const menuItems = TEMPLATE1_MENUS[activeSection];
|
||||||
|
|
||||||
|
const homeMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
TEMPLATE1_SECTIONS.filter((item) => item.id !== 'home')
|
||||||
|
.map((item) => ({
|
||||||
|
title: item.label,
|
||||||
|
description: item.description,
|
||||||
|
icon:
|
||||||
|
item.id === 'projects' ? <ThunderboltOutlined /> :
|
||||||
|
item.id === 'resources' ? <AppstoreAddOutlined /> :
|
||||||
|
<BellOutlined />,
|
||||||
|
screen: item.id,
|
||||||
|
actionLabel: '열기',
|
||||||
|
chips: item.chips,
|
||||||
|
actionType: 'navigate',
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goSection = (section: Template1SectionId) => {
|
||||||
|
setActiveSection(section);
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openItem = (item: Template1MenuItem) => {
|
||||||
|
if (item.actionType === 'exit') {
|
||||||
|
onBack();
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.settingAction === 'reset-state') {
|
||||||
|
goSection('home');
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.actionType === 'local') {
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.screen) {
|
||||||
|
goSection(item.screen);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHomeIconClick = () => {
|
||||||
|
goSection('home');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActiveScreen = () => {
|
||||||
|
if (activeSection === 'home') {
|
||||||
|
return <Template1HomeScreen items={homeMenuItems} onItemSelect={openItem} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Template1SectionScreen
|
||||||
|
title={activeSectionItem.label}
|
||||||
|
chips={activeSectionItem.chips}
|
||||||
|
cards={menuItems}
|
||||||
|
onItemSelect={openItem}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="template1-app">
|
||||||
|
<header className="template1-app__topbar">
|
||||||
|
<div className="template1-app__brand">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="template1-app__badge"
|
||||||
|
aria-label="Template1 홈으로 이동"
|
||||||
|
onClick={handleHomeIconClick}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleHomeIconClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppstoreOutlined />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1>Template1 앱 레이아웃</h1>
|
||||||
|
<p>다음 앱에 바로 적용 가능한 기본 배치 예시</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="template1-app__topbar-action"
|
||||||
|
aria-label="Template1 설정 열기"
|
||||||
|
aria-expanded={isSettingsOpen}
|
||||||
|
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||||
|
title="설정"
|
||||||
|
>
|
||||||
|
<SettingOutlined />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isSettingsOpen ? (
|
||||||
|
<Template1SettingsDropdown onItemSelect={openItem} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<main className="template1-app__main">
|
||||||
|
<div className="template1-app__scroll-area">
|
||||||
|
<div key={`template1-section-${activeSection}-${isSettingsOpen ? 'settings' : 'screen'}`} className="template1-app__screen-transition">
|
||||||
|
{renderActiveScreen()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user