feat: add play apps and layout tools

This commit is contained in:
2026-05-25 17:29:21 +09:00
parent f59522ffc4
commit 51e0099bea
46 changed files with 37152 additions and 119 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { LayoutDrawPage } from './LayoutDrawPage';

View File

@@ -0,0 +1,246 @@
import { appendClientIdHeader } from '../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../app/main/tokenAccess';
import type { SavedLayoutDrawComponentRecord } from './layoutDrawTypes';
import { normalizeSavedLayoutDrawShapes, serializeSavedLayoutDrawShapes } from './layoutDrawStorageShapes.ts';
type SavedLayoutDrawComponentRow = {
id: string;
name: string;
category: string;
created_at: string;
updated_at: string;
shapes: SavedLayoutDrawComponentRecord['shapes'] | string;
};
const WORK_SERVER_TIMEOUT_MS = 8000;
const LAYOUT_DRAW_COMPONENT_TABLE = 'layout_draw_components';
let setupPromise: Promise<void> | null = null;
function normalizeTimestamp(value: unknown, fallback: string) {
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
}
return fallback;
}
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveWorkServerFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalHost) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
const WORK_SERVER_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
class LayoutDrawComponentStorageError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'LayoutDrawComponentStorageError';
this.status = status;
}
}
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null;
const method = init?.method?.toUpperCase() ?? 'GET';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
let response: Response;
try {
response = await fetch(`${baseUrl}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
});
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof DOMException && error.name === 'AbortError') {
throw new LayoutDrawComponentStorageError('컴포넌트 저장소 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error;
}
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text();
let message = text || '컴포넌트 저장소 요청에 실패했습니다.';
try {
const payload = JSON.parse(text) as { message?: string };
message = payload.message || message;
} catch {
// Keep raw text.
}
throw new LayoutDrawComponentStorageError(message, response.status);
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('application/json')) {
throw new LayoutDrawComponentStorageError('컴포넌트 저장소 응답이 JSON이 아닙니다.', 502);
}
return response.json() as Promise<T>;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
return await requestOnce<T>(WORK_SERVER_BASE_URL, path, init);
} catch (error) {
const shouldRetry =
WORK_SERVER_FALLBACK_BASE_URL &&
WORK_SERVER_FALLBACK_BASE_URL !== WORK_SERVER_BASE_URL &&
(error instanceof LayoutDrawComponentStorageError
? error.status === 404 || error.status === 408 || error.status === 502
: error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message)));
if (!shouldRetry) {
throw error;
}
return requestOnce<T>(WORK_SERVER_FALLBACK_BASE_URL, path, init);
}
}
function toRecord(row: SavedLayoutDrawComponentRow): SavedLayoutDrawComponentRecord {
const now = new Date().toISOString();
return {
id: row.id,
name: row.name,
category: row.category,
createdAt: normalizeTimestamp(row.created_at, now),
updatedAt: normalizeTimestamp(row.updated_at, now),
shapes: normalizeSavedLayoutDrawShapes(row.shapes).filter(
(shape) => shape.type === 'line' || shape.type === 'rect',
) as SavedLayoutDrawComponentRecord['shapes'],
};
}
async function ensureLayoutDrawComponentTable() {
if (!setupPromise) {
setupPromise = (async () => {
const schemaResponse = await request<{ items: Array<{ table_name: string }> }>('/schema/tables');
const tableExists = schemaResponse.items.some((item) => item.table_name === LAYOUT_DRAW_COMPONENT_TABLE);
if (tableExists) {
return;
}
try {
await request<{ ok: boolean; tableName: string }>('/ddl/create-table', {
method: 'POST',
body: JSON.stringify({
tableName: LAYOUT_DRAW_COMPONENT_TABLE,
columns: [
{ name: 'id', type: 'text', nullable: false, primary: true },
{ name: 'name', type: 'text', nullable: false },
{ name: 'category', type: 'text', nullable: false },
{ name: 'created_at', type: 'timestamp with time zone', nullable: false },
{ name: 'updated_at', type: 'timestamp with time zone', nullable: false },
{ name: 'shapes', type: 'jsonb', nullable: false },
],
}),
});
} catch (error) {
if (!(error instanceof LayoutDrawComponentStorageError) || !/already exists/i.test(error.message)) {
throw error;
}
}
})().catch((error) => {
setupPromise = null;
throw error;
});
}
return setupPromise;
}
export async function listSavedLayoutDrawComponents() {
await ensureLayoutDrawComponentTable();
const response = await request<{ rows: SavedLayoutDrawComponentRow[] }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/select`, {
method: 'POST',
body: JSON.stringify({
orderBy: [{ field: 'updated_at', direction: 'desc' }],
limit: 200,
}),
});
return response.rows.map(toRecord);
}
export async function saveLayoutDrawComponent(record: SavedLayoutDrawComponentRecord) {
await ensureLayoutDrawComponentTable();
await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/insert`, {
method: 'POST',
body: JSON.stringify({
data: {
id: record.id,
name: record.name,
category: record.category,
created_at: record.createdAt,
updated_at: record.updatedAt,
shapes: serializeSavedLayoutDrawShapes(record.shapes),
},
}),
});
}
export async function deleteLayoutDrawComponent(id: string) {
await ensureLayoutDrawComponentTable();
await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/delete`, {
method: 'DELETE',
body: JSON.stringify({
where: [{ field: 'id', operator: 'eq', value: id }],
}),
});
}

View File

@@ -0,0 +1,68 @@
import type { LayoutDrawDocument } from './layoutDrawTypes';
export type LayoutDrawHistoryState = {
past: LayoutDrawDocument[];
present: LayoutDrawDocument;
future: LayoutDrawDocument[];
};
function cloneDocument(document: LayoutDrawDocument): LayoutDrawDocument {
return {
backgroundMode: document.backgroundMode,
shapes: document.shapes.map((shape) => ({ ...shape })),
};
}
function isDocumentEqual(left: LayoutDrawDocument, right: LayoutDrawDocument) {
return JSON.stringify(left) === JSON.stringify(right);
}
export function createLayoutDrawHistoryState(initialDocument: LayoutDrawDocument): LayoutDrawHistoryState {
return {
past: [],
present: cloneDocument(initialDocument),
future: [],
};
}
export function commitLayoutDrawHistory(
history: LayoutDrawHistoryState,
nextDocument: LayoutDrawDocument,
): LayoutDrawHistoryState {
const nextSnapshot = cloneDocument(nextDocument);
if (isDocumentEqual(history.present, nextSnapshot)) {
return history;
}
return {
past: [...history.past, cloneDocument(history.present)],
present: nextSnapshot,
future: [],
};
}
export function undoLayoutDrawHistory(history: LayoutDrawHistoryState): LayoutDrawHistoryState {
if (history.past.length === 0) {
return history;
}
const previous = history.past[history.past.length - 1];
return {
past: history.past.slice(0, -1),
present: cloneDocument(previous),
future: [cloneDocument(history.present), ...history.future],
};
}
export function redoLayoutDrawHistory(history: LayoutDrawHistoryState): LayoutDrawHistoryState {
if (history.future.length === 0) {
return history;
}
const [next, ...restFuture] = history.future;
return {
past: [...history.past, cloneDocument(history.present)],
present: cloneDocument(next),
future: restFuture,
};
}

View File

@@ -0,0 +1,237 @@
import type { DrawLine, DrawRect, DrawRegion, DrawableShape } from './layoutDrawTypes';
type RegionCell = {
x: number;
y: number;
width: number;
height: number;
};
export type ResolvedDrawRegion = {
key: string;
cells: RegionCell[];
labelPosition: {
x: number;
y: number;
};
label: string;
fillColor: string | null;
assignmentId: string | null;
};
function normalizeLine(line: DrawLine) {
return {
x1: Math.min(line.x1, line.x2),
y1: Math.min(line.y1, line.y2),
x2: Math.max(line.x1, line.x2),
y2: Math.max(line.y1, line.y2),
};
}
function uniqueSorted(values: number[]) {
return [...new Set(values.filter((value) => Number.isFinite(value)))].sort((left, right) => left - right);
}
function isVerticalBoundaryBlocked(verticalLines: DrawLine[], rects: DrawRect[], x: number, startY: number, endY: number) {
if (
verticalLines.some((line) => {
const normalized = normalizeLine(line);
return normalized.x1 === x && normalized.y1 <= startY && normalized.y2 >= endY;
})
) {
return true;
}
return rects.some(
(rect) =>
(rect.x === x || rect.x + rect.width === x) &&
rect.y <= startY &&
rect.y + rect.height >= endY,
);
}
function isHorizontalBoundaryBlocked(horizontalLines: DrawLine[], rects: DrawRect[], y: number, startX: number, endX: number) {
if (
horizontalLines.some((line) => {
const normalized = normalizeLine(line);
return normalized.y1 === y && normalized.x1 <= startX && normalized.x2 >= endX;
})
) {
return true;
}
return rects.some(
(rect) =>
(rect.y === y || rect.y + rect.height === y) &&
rect.x <= startX &&
rect.x + rect.width >= endX,
);
}
export function splitDrawShapes(shapes: DrawableShape[] | (DrawableShape | DrawRegion)[]) {
const drawableShapes: DrawableShape[] = [];
const regionAssignments: DrawRegion[] = [];
shapes.forEach((shape) => {
if (shape.type === 'region') {
regionAssignments.push(shape);
return;
}
drawableShapes.push(shape);
});
return { drawableShapes, regionAssignments };
}
export function resolveDrawRegions(
shapes: DrawableShape[] | (DrawableShape | DrawRegion)[],
canvasWidth: number,
canvasHeight: number,
): ResolvedDrawRegion[] {
if (canvasWidth <= 0 || canvasHeight <= 0) {
return [];
}
const { drawableShapes, regionAssignments } = splitDrawShapes(shapes);
const lines = drawableShapes.filter((shape): shape is DrawLine => shape.type === 'line');
const rects = drawableShapes.filter((shape): shape is DrawRect => shape.type === 'rect');
const verticalLines = lines.filter((line) => line.orientation === 'vertical');
const horizontalLines = lines.filter((line) => line.orientation === 'horizontal');
const xs = uniqueSorted([
0,
canvasWidth,
...verticalLines.map((line) => line.x1),
...rects.flatMap((rect) => [rect.x, rect.x + rect.width]),
]);
const ys = uniqueSorted([
0,
canvasHeight,
...horizontalLines.map((line) => line.y1),
...rects.flatMap((rect) => [rect.y, rect.y + rect.height]),
]);
if (xs.length < 2 || ys.length < 2) {
return [];
}
const visited = new Set<string>();
const assignmentMap = new Map(regionAssignments.map((shape) => [shape.regionKey, shape]));
const regions: ResolvedDrawRegion[] = [];
for (let xIndex = 0; xIndex < xs.length - 1; xIndex += 1) {
for (let yIndex = 0; yIndex < ys.length - 1; yIndex += 1) {
const startKey = `${xIndex}:${yIndex}`;
if (visited.has(startKey)) {
continue;
}
const stack: Array<[number, number]> = [[xIndex, yIndex]];
const cells: RegionCell[] = [];
const cellKeys: string[] = [];
let totalArea = 0;
let weightedCenterX = 0;
let weightedCenterY = 0;
while (stack.length > 0) {
const [currentXIndex, currentYIndex] = stack.pop() as [number, number];
const currentKey = `${currentXIndex}:${currentYIndex}`;
if (visited.has(currentKey)) {
continue;
}
visited.add(currentKey);
cellKeys.push(currentKey);
const cell = {
x: xs[currentXIndex],
y: ys[currentYIndex],
width: xs[currentXIndex + 1] - xs[currentXIndex],
height: ys[currentYIndex + 1] - ys[currentYIndex],
};
cells.push(cell);
const area = cell.width * cell.height;
totalArea += area;
weightedCenterX += (cell.x + cell.width / 2) * area;
weightedCenterY += (cell.y + cell.height / 2) * area;
if (
currentXIndex > 0 &&
!isVerticalBoundaryBlocked(verticalLines, rects, xs[currentXIndex], cell.y, cell.y + cell.height)
) {
stack.push([currentXIndex - 1, currentYIndex]);
}
if (
currentXIndex < xs.length - 2 &&
!isVerticalBoundaryBlocked(verticalLines, rects, xs[currentXIndex + 1], cell.y, cell.y + cell.height)
) {
stack.push([currentXIndex + 1, currentYIndex]);
}
if (
currentYIndex > 0 &&
!isHorizontalBoundaryBlocked(horizontalLines, rects, ys[currentYIndex], cell.x, cell.x + cell.width)
) {
stack.push([currentXIndex, currentYIndex - 1]);
}
if (
currentYIndex < ys.length - 2 &&
!isHorizontalBoundaryBlocked(horizontalLines, rects, ys[currentYIndex + 1], cell.x, cell.x + cell.width)
) {
stack.push([currentXIndex, currentYIndex + 1]);
}
}
if (cells.length === 0) {
continue;
}
const centerX = totalArea > 0 ? weightedCenterX / totalArea : cells[0].x + cells[0].width / 2;
const centerY = totalArea > 0 ? weightedCenterY / totalArea : cells[0].y + cells[0].height / 2;
const anchorCell = cells.reduce((closest, cell) => {
const closestDistance = Math.hypot(
closest.x + closest.width / 2 - centerX,
closest.y + closest.height / 2 - centerY,
);
const currentDistance = Math.hypot(cell.x + cell.width / 2 - centerX, cell.y + cell.height / 2 - centerY);
return currentDistance < closestDistance ? cell : closest;
}, cells[0]);
const key = cellKeys.sort().join('|');
const assignment = assignmentMap.get(key) ?? null;
regions.push({
key,
cells,
labelPosition: {
x: anchorCell.x + anchorCell.width / 2,
y: anchorCell.y + anchorCell.height / 2,
},
label: assignment?.label ?? '',
fillColor: assignment?.fillColor ?? null,
assignmentId: assignment?.id ?? null,
});
}
}
return regions;
}
export function findRegionAtPoint(regions: ResolvedDrawRegion[], x: number, y: number) {
for (let index = regions.length - 1; index >= 0; index -= 1) {
const region = regions[index];
if (
region.cells.some(
(cell) => x >= cell.x && x <= cell.x + cell.width && y >= cell.y && y <= cell.y + cell.height,
)
) {
return region;
}
}
return null;
}

View File

@@ -0,0 +1,99 @@
import type { DrawRect, DrawableShape } from './layoutDrawTypes';
export type SelectionRect = {
x: number;
y: number;
width: number;
height: number;
};
export function clampSelectionRect(startX: number, startY: number, endX: number, endY: number): SelectionRect {
const x = Math.min(startX, endX);
const y = Math.min(startY, endY);
return {
x,
y,
width: Math.abs(endX - startX),
height: Math.abs(endY - startY),
};
}
export function resolveGroupedShapeIds(shapes: DrawableShape[], shapeIds: Iterable<string>) {
const seedIds = new Set(shapeIds);
if (seedIds.size === 0) {
return new Set<string>();
}
const matchedShapeIds = new Set(shapes.filter((shape) => seedIds.has(shape.id)).map((shape) => shape.id));
if (matchedShapeIds.size === 0) {
return seedIds;
}
const groupIds = new Set(
shapes.filter((shape) => seedIds.has(shape.id) && shape.groupId).map((shape) => shape.groupId as string),
);
return new Set(
shapes
.filter((shape) => seedIds.has(shape.id) || (shape.groupId ? groupIds.has(shape.groupId) : false))
.map((shape) => shape.id),
);
}
function isRectIntersectingSelection(shape: DrawRect, selection: SelectionRect) {
return !(
shape.x + shape.width < selection.x ||
shape.x > selection.x + selection.width ||
shape.y + shape.height < selection.y ||
shape.y > selection.y + selection.height
);
}
function isLineIntersectingSelection(shape: DrawableShape, selection: SelectionRect) {
if (shape.type !== 'line') {
return false;
}
const minX = Math.min(shape.x1, shape.x2);
const maxX = Math.max(shape.x1, shape.x2);
const minY = Math.min(shape.y1, shape.y2);
const maxY = Math.max(shape.y1, shape.y2);
return !(
maxX < selection.x ||
minX > selection.x + selection.width ||
maxY < selection.y ||
minY > selection.y + selection.height
);
}
export function findShapesInSelection(shapes: DrawableShape[], selection: SelectionRect) {
return shapes.filter((shape) =>
shape.type === 'rect' ? isRectIntersectingSelection(shape, selection) : isLineIntersectingSelection(shape, selection),
);
}
export function rebaseShapesToComponentBlueprint(shapes: DrawableShape[]) {
if (shapes.length === 0) {
return [];
}
const minX = Math.min(...shapes.map((shape) => (shape.type === 'line' ? Math.min(shape.x1, shape.x2) : shape.x)));
const minY = Math.min(...shapes.map((shape) => (shape.type === 'line' ? Math.min(shape.y1, shape.y2) : shape.y)));
return shapes.map((shape) =>
shape.type === 'line'
? {
...shape,
x1: shape.x1 - minX,
y1: shape.y1 - minY,
x2: shape.x2 - minX,
y2: shape.y2 - minY,
}
: {
...shape,
x: shape.x - minX,
y: shape.y - minY,
},
);
}

View File

@@ -0,0 +1,32 @@
import type { DrawableShape } from './layoutDrawTypes';
const DUPLICATE_OFFSET_PX = 24;
export function duplicateShapeWithLabel(
shape: DrawableShape,
nextId: string,
label = shape.label,
groupId = shape.groupId ?? null,
): DrawableShape {
if (shape.type === 'line') {
return {
...shape,
id: nextId,
...(groupId ? { groupId } : {}),
label,
x1: shape.x1 + DUPLICATE_OFFSET_PX,
y1: shape.y1 + DUPLICATE_OFFSET_PX,
x2: shape.x2 + DUPLICATE_OFFSET_PX,
y2: shape.y2 + DUPLICATE_OFFSET_PX,
};
}
return {
...shape,
id: nextId,
...(groupId ? { groupId } : {}),
label,
x: shape.x + DUPLICATE_OFFSET_PX,
y: shape.y + DUPLICATE_OFFSET_PX,
};
}

View File

@@ -0,0 +1,244 @@
import { appendClientIdHeader } from '../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../app/main/tokenAccess';
import type { SavedLayoutDrawRecord } from './layoutDrawTypes';
import { normalizeSavedLayoutDrawShapes, serializeSavedLayoutDrawShapes } from './layoutDrawStorageShapes.ts';
type SavedLayoutDrawRow = {
id: string;
name: string;
created_at: string;
updated_at: string;
background_mode: SavedLayoutDrawRecord['backgroundMode'];
shapes: SavedLayoutDrawRecord['shapes'] | string;
};
const WORK_SERVER_TIMEOUT_MS = 8000;
const LAYOUT_DRAW_TABLE = 'layout_draw_snapshots';
let setupPromise: Promise<void> | null = null;
function normalizeTimestamp(value: unknown, fallback: string) {
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
}
return fallback;
}
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveWorkServerFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalHost) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
const WORK_SERVER_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
class LayoutDrawStorageError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'LayoutDrawStorageError';
this.status = status;
}
}
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null;
const method = init?.method?.toUpperCase() ?? 'GET';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
let response: Response;
try {
response = await fetch(`${baseUrl}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
});
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof DOMException && error.name === 'AbortError') {
throw new LayoutDrawStorageError('저장소 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error;
}
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text();
let message = text || '도면 저장소 요청에 실패했습니다.';
try {
const payload = JSON.parse(text) as { message?: string };
message = payload.message || message;
} catch {
// Keep raw text.
}
throw new LayoutDrawStorageError(message, response.status);
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('application/json')) {
throw new LayoutDrawStorageError('도면 저장소 응답이 JSON이 아닙니다.', 502);
}
return response.json() as Promise<T>;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
return await requestOnce<T>(WORK_SERVER_BASE_URL, path, init);
} catch (error) {
const shouldRetry =
WORK_SERVER_FALLBACK_BASE_URL &&
WORK_SERVER_FALLBACK_BASE_URL !== WORK_SERVER_BASE_URL &&
(error instanceof LayoutDrawStorageError
? error.status === 404 || error.status === 408 || error.status === 502
: error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message)));
if (!shouldRetry) {
throw error;
}
return requestOnce<T>(WORK_SERVER_FALLBACK_BASE_URL, path, init);
}
}
function toRecord(row: SavedLayoutDrawRow): SavedLayoutDrawRecord {
const now = new Date().toISOString();
return {
id: row.id,
name: row.name,
createdAt: normalizeTimestamp(row.created_at, now),
updatedAt: normalizeTimestamp(row.updated_at, now),
backgroundMode: row.background_mode === 'plain' ? 'plain' : 'grid',
shapes: normalizeSavedLayoutDrawShapes(row.shapes),
};
}
async function ensureLayoutDrawTable() {
if (!setupPromise) {
setupPromise = (async () => {
const schemaResponse = await request<{ items: Array<{ table_name: string }> }>('/schema/tables');
const tableExists = schemaResponse.items.some((item) => item.table_name === LAYOUT_DRAW_TABLE);
if (tableExists) {
return;
}
try {
await request<{ ok: boolean; tableName: string }>('/ddl/create-table', {
method: 'POST',
body: JSON.stringify({
tableName: LAYOUT_DRAW_TABLE,
columns: [
{ name: 'id', type: 'text', nullable: false, primary: true },
{ name: 'name', type: 'text', nullable: false },
{ name: 'created_at', type: 'timestamp with time zone', nullable: false },
{ name: 'updated_at', type: 'timestamp with time zone', nullable: false },
{ name: 'background_mode', type: 'text', nullable: false },
{ name: 'shapes', type: 'jsonb', nullable: false },
],
}),
});
} catch (error) {
if (!(error instanceof LayoutDrawStorageError) || !/already exists/i.test(error.message)) {
throw error;
}
}
})().catch((error) => {
setupPromise = null;
throw error;
});
}
return setupPromise;
}
export async function listSavedLayoutDraws() {
await ensureLayoutDrawTable();
const response = await request<{ rows: SavedLayoutDrawRow[] }>(`/crud/${LAYOUT_DRAW_TABLE}/select`, {
method: 'POST',
body: JSON.stringify({
orderBy: [{ field: 'updated_at', direction: 'desc' }],
limit: 200,
}),
});
return response.rows.map(toRecord);
}
export async function saveLayoutDraw(record: SavedLayoutDrawRecord) {
await ensureLayoutDrawTable();
await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_TABLE}/insert`, {
method: 'POST',
body: JSON.stringify({
data: {
id: record.id,
name: record.name,
created_at: record.createdAt,
updated_at: record.updatedAt,
background_mode: record.backgroundMode,
shapes: serializeSavedLayoutDrawShapes(record.shapes),
},
}),
});
}
export async function deleteLayoutDraw(id: string) {
await ensureLayoutDrawTable();
await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_TABLE}/delete`, {
method: 'DELETE',
body: JSON.stringify({
where: [{ field: 'id', operator: 'eq', value: id }],
}),
});
}

View File

@@ -0,0 +1,24 @@
import type { SavedLayoutDrawRecord } from './layoutDrawTypes.ts';
export function normalizeSavedLayoutDrawShapes(
value: SavedLayoutDrawRecord['shapes'] | string | null | undefined,
): SavedLayoutDrawRecord['shapes'] {
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? (parsed as SavedLayoutDrawRecord['shapes']) : [];
} catch {
return [];
}
}
return [];
}
export function serializeSavedLayoutDrawShapes(shapes: SavedLayoutDrawRecord['shapes']) {
return JSON.stringify(Array.isArray(shapes) ? shapes : []);
}

View File

@@ -0,0 +1,62 @@
export type DrawTool = 'select' | 'line' | 'rect' | 'paint';
export type BackgroundMode = 'grid' | 'plain';
export type DrawLine = {
id: string;
type: 'line';
groupId?: string | null;
x1: number;
y1: number;
x2: number;
y2: number;
orientation: 'horizontal' | 'vertical';
label: string;
};
export type DrawRect = {
id: string;
type: 'rect';
groupId?: string | null;
x: number;
y: number;
width: number;
height: number;
label: string;
fillColor?: string | null;
};
export type DrawRegion = {
id: string;
type: 'region';
regionKey: string;
label: string;
fillColor?: string | null;
};
export type DrawableShape = DrawLine | DrawRect;
export type DrawShape = DrawableShape | DrawRegion;
export type LayoutDrawDocument = {
backgroundMode: BackgroundMode;
shapes: DrawShape[];
};
export type SavedLayoutDrawRecord = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
backgroundMode: BackgroundMode;
shapes: DrawShape[];
};
export type SavedLayoutDrawComponentRecord = {
id: string;
name: string;
category: string;
createdAt: string;
updatedAt: string;
shapes: DrawableShape[];
};

View File

@@ -0,0 +1,107 @@
export type LineOrientation = 'horizontal' | 'vertical';
export type DrawLineLike = {
type: 'line';
x1: number;
y1: number;
x2: number;
y2: number;
orientation: LineOrientation;
};
export type DrawShapeLike =
| DrawLineLike
| {
type: 'rect';
x: number;
y: number;
width: number;
height: number;
};
function isBetween(value: number, start: number, end: number) {
const min = Math.min(start, end);
const max = Math.max(start, end);
return value >= min && value <= max;
}
function resolveNearestCoordinate(
start: number,
candidates: number[],
direction: -1 | 1,
) {
return candidates.reduce<number | null>((closest, candidate) => {
if ((candidate - start) * direction <= 0) {
return closest;
}
if (closest === null) {
return candidate;
}
return Math.abs(candidate - start) < Math.abs(closest - start) ? candidate : closest;
}, null);
}
function resolveLineOrientation(
startX: number,
startY: number,
pointerX: number,
pointerY: number,
): LineOrientation {
const deltaX = pointerX - startX;
const deltaY = pointerY - startY;
return Math.abs(deltaX) >= Math.abs(deltaY) ? 'vertical' : 'horizontal';
}
export function resolveLineDraft(
startX: number,
startY: number,
pointerX: number,
pointerY: number,
shapes: DrawShapeLike[],
canvasWidth: number,
canvasHeight: number,
preferredOrientation?: LineOrientation,
) {
const orientation =
preferredOrientation ?? resolveLineOrientation(startX, startY, pointerX, pointerY);
if (orientation === 'horizontal') {
const crossingXs = shapes.flatMap<number>((shape) => {
if (shape.type !== 'line' || shape.orientation !== 'vertical') {
return [];
}
return isBetween(startY, shape.y1, shape.y2) ? [shape.x1] : [];
});
const previousStop = resolveNearestCoordinate(startX, crossingXs, -1);
const nextStop = resolveNearestCoordinate(startX, crossingXs, 1);
return {
startX: previousStop ?? 0,
startY,
endX: nextStop ?? canvasWidth,
endY: startY,
orientation,
};
}
const crossingYs = shapes.flatMap<number>((shape) => {
if (shape.type !== 'line' || shape.orientation !== 'horizontal') {
return [];
}
return isBetween(startX, shape.x1, shape.x2) ? [shape.y1] : [];
});
const previousStop = resolveNearestCoordinate(startY, crossingYs, -1);
const nextStop = resolveNearestCoordinate(startY, crossingYs, 1);
return {
startX,
startY: previousStop ?? 0,
endX: startX,
endY: nextStop ?? canvasHeight,
orientation,
};
}

View File

@@ -135,7 +135,7 @@
min-height: 0; min-height: 0;
height: 100%; height: 100%;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: hidden;
} }
.feature-menu-layout-page__tabs .ant-tabs-nav { .feature-menu-layout-page__tabs .ant-tabs-nav {
@@ -251,7 +251,7 @@
.feature-menu-layout-page__editor-shell { .feature-menu-layout-page__editor-shell {
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
align-self: stretch; align-self: stretch;
height: calc(100% - 24px); height: 100%;
} }
.feature-menu-layout-page__field:first-of-type { .feature-menu-layout-page__field:first-of-type {
@@ -290,14 +290,14 @@
.feature-menu-layout-page__textarea.ant-input { .feature-menu-layout-page__textarea.ant-input {
align-self: stretch; align-self: stretch;
height: calc(100% - 4px) !important; height: 100% !important;
min-height: 0 !important; min-height: 0 !important;
max-height: none; max-height: none;
padding: 8px 10px; padding: 8px 10px;
} }
.feature-menu-layout-page__notes { .feature-menu-layout-page__notes {
height: calc(100% - 4px); height: 100%;
max-height: none; max-height: none;
padding: 7px 12px 7px; padding: 7px 12px 7px;
padding-bottom: 7px; padding-bottom: 7px;

View File

@@ -3,6 +3,7 @@ import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess';
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel'; import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel';
import { buildChatPath } from '../../../app/main/routes'; import { buildChatPath } from '../../../app/main/routes';
import { useTokenAccess } from '../../../app/main/tokenAccess'; import { useTokenAccess } from '../../../app/main/tokenAccess';
@@ -251,6 +252,7 @@ export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsCh
okText: '삭제', okText: '삭제',
cancelText: '취소', cancelText: '취소',
okButtonProps: { danger: true }, okButtonProps: { danger: true },
modalRender: renderModalWithEnterConfirm,
async onOk() { async onOk() {
const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id); const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id);
const nextTree = const nextTree =

View File

@@ -32,6 +32,7 @@ import {
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react'; import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppConfig, type AppConfig } from '../../app/main/appConfig'; import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
import { confirmWithKeyboard } from '../../app/main/modalKeyboard';
import { import {
buildAutomationTypeOptions, buildAutomationTypeOptions,
resolveAutomationTypeLabel, resolveAutomationTypeLabel,
@@ -646,6 +647,7 @@ export function PlanBoardPage({
const appConfig = useAppConfig(); const appConfig = useAppConfig();
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000; const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const [items, setItems] = useState<PlanItem[]>([]); const [items, setItems] = useState<PlanItem[]>([]);
const [reviewIndicatorsByPlanId, setReviewIndicatorsByPlanId] = useState<Record<number, ReviewListIndicator>>({}); const [reviewIndicatorsByPlanId, setReviewIndicatorsByPlanId] = useState<Record<number, ReviewListIndicator>>({});
@@ -1415,7 +1417,14 @@ export function PlanBoardPage({
return; return;
} }
if (!window.confirm('선택한 작업 메모를 삭제할까요?')) { const confirmed = await confirmWithKeyboard(modalApi, {
title: '선택한 작업 메모를 삭제할까요?',
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return; return;
} }
@@ -1748,6 +1757,7 @@ export function PlanBoardPage({
return ( return (
<div className="plan-board-page"> <div className="plan-board-page">
{contextHolder} {contextHolder}
{modalContextHolder}
{isMobileAutomationLayout ? null : ( {isMobileAutomationLayout ? null : (
<Card className="plan-board-page__overview" bordered={false}> <Card className="plan-board-page__overview" bordered={false}>

View File

@@ -15,6 +15,7 @@ import {
Tabs, Tabs,
Tag, Tag,
Typography, Typography,
Modal,
message, message,
} from 'antd'; } from 'antd';
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
@@ -30,6 +31,7 @@ import {
} from '../../app/main/automationContextAccess'; } from '../../app/main/automationContextAccess';
import { buildPlansPath } from '../../app/main/routes'; import { buildPlansPath } from '../../app/main/routes';
import { useTokenAccess } from '../../app/main/tokenAccess'; import { useTokenAccess } from '../../app/main/tokenAccess';
import { confirmWithKeyboard } from '../../app/main/modalKeyboard';
import './planBoard.css'; import './planBoard.css';
import './planSchedule.css'; import './planSchedule.css';
import { maskNotePreviewByWord } from './noteMasking'; import { maskNotePreviewByWord } from './noteMasking';
@@ -596,6 +598,7 @@ export function PlanSchedulePage() {
const { automationTypes } = useAutomationTypeRegistry(); const { automationTypes } = useAutomationTypeRegistry();
const { automationContexts } = useAutomationContextRegistry(); const { automationContexts } = useAutomationContextRegistry();
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const [items, setItems] = useState<PlanScheduledTask[]>([]); const [items, setItems] = useState<PlanScheduledTask[]>([]);
const [draft, setDraft] = useState(() => createEmptyScheduleDraft()); const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
@@ -701,7 +704,14 @@ export function PlanSchedulePage() {
return; return;
} }
if (!window.confirm('선택한 스케줄을 삭제할까요?')) { const confirmed = await confirmWithKeyboard(modalApi, {
title: '선택한 스케줄을 삭제할까요?',
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return; return;
} }
@@ -752,6 +762,7 @@ export function PlanSchedulePage() {
return ( return (
<div className="plan-schedule-page"> <div className="plan-schedule-page">
{contextHolder} {contextHolder}
{modalContextHolder}
<Card className="plan-schedule-page__overview" bordered={false}> <Card className="plan-schedule-page__overview" bordered={false}>
<Flex justify="space-between" align="center" gap={12} wrap> <Flex justify="space-between" align="center" gap={12} wrap>
<div> <div>

View File

@@ -0,0 +1,217 @@
.apps-library {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
height: 100%;
min-height: 0;
padding: clamp(12px, 1.4vw, 18px);
border-radius: 20px;
color: #f7f9fc;
background:
linear-gradient(135deg, rgba(255, 163, 92, 0.2), transparent 32%),
radial-gradient(circle at bottom right, rgba(93, 166, 255, 0.22), transparent 28%),
linear-gradient(180deg, #10192b 0%, #0a1020 100%);
overflow: hidden;
}
.apps-library__topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.apps-library__title {
display: flex;
align-items: baseline;
gap: 8px;
}
.apps-library__title strong {
font-size: clamp(18px, 2vw, 24px);
}
.apps-library__title span {
font-size: 12px;
color: rgba(247, 249, 252, 0.68);
}
.apps-library__shelf {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
min-height: 0;
align-content: start;
}
.apps-library__card {
display: grid;
justify-items: start;
align-content: end;
gap: 8px;
min-height: 120px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
overflow: hidden;
text-align: left;
color: inherit;
background-color: rgba(255, 255, 255, 0.06);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
cursor: pointer;
}
.apps-library__card--puzzle {
background:
linear-gradient(180deg, rgba(255, 167, 86, 0.24), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.06);
}
.apps-library__card--photoprism {
background:
linear-gradient(180deg, rgba(228, 177, 95, 0.24), rgba(94, 165, 255, 0.12)),
rgba(255, 255, 255, 0.06);
}
.apps-library__card--reader {
background:
linear-gradient(180deg, rgba(96, 219, 255, 0.26), rgba(59, 118, 255, 0.16)),
rgba(255, 255, 255, 0.06);
}
.apps-library__card--beat {
background:
linear-gradient(180deg, rgba(127, 114, 255, 0.18), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.05);
}
.apps-library__card--tetris {
background:
linear-gradient(180deg, rgba(255, 212, 84, 0.24), rgba(255, 119, 82, 0.12)),
rgba(255, 255, 255, 0.06);
}
.apps-library__card--the-quest {
background:
linear-gradient(180deg, rgba(255, 201, 112, 0.24), rgba(104, 198, 255, 0.14)),
rgba(255, 255, 255, 0.06);
}
.apps-library__card--sticker {
background:
linear-gradient(180deg, rgba(255, 95, 149, 0.18), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.05);
}
.apps-library__card--launch {
background:
linear-gradient(180deg, rgba(94, 183, 255, 0.18), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.05);
}
.apps-library__card--arcade {
background:
linear-gradient(180deg, rgba(255, 115, 92, 0.18), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.05);
}
.apps-library__card--vault {
background:
linear-gradient(180deg, rgba(96, 255, 194, 0.16), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.05);
}
.apps-library__icon {
display: inline-flex;
width: 42px;
height: 42px;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 20px;
background: rgba(255, 255, 255, 0.12);
}
.apps-library__card strong {
font-size: 16px;
line-height: 1.1;
}
.apps-library__meta {
font-size: 12px;
color: rgba(247, 249, 252, 0.72);
}
.apps-library__card:disabled {
opacity: 0.72;
cursor: default;
}
@media (max-width: 960px) {
.apps-library__shelf {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.apps-library {
gap: 10px;
padding: 10px;
border-radius: 16px;
}
.apps-library__topbar .ant-tag {
font-size: 11px;
padding-inline: 8px;
}
.apps-library__shelf,
.apps-library__shelf--compact {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.apps-library__card {
min-height: 78px;
gap: 6px;
padding: 10px 8px;
border-radius: 12px;
}
.apps-library__title strong {
font-size: 18px;
}
.apps-library__icon {
width: 26px;
height: 26px;
border-radius: 8px;
font-size: 13px;
}
.apps-library__card strong {
font-size: 12px;
}
.apps-library__meta {
font-size: 10px;
}
}
@media (max-width: 480px) {
.apps-library__shelf,
.apps-library__shelf--compact {
gap: 6px;
}
.apps-library__card {
min-height: 72px;
padding: 8px 7px;
}
.apps-library__icon {
width: 22px;
height: 22px;
font-size: 11px;
}
}

View File

@@ -0,0 +1,133 @@
import { Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import './AppsLibraryView.css';
import { EReaderAppView } from '../e-reader/EReaderAppView';
import { PhotoPrismAppView } from '../photoprism/PhotoPrismAppView';
import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView';
import { TheQuestAppView } from '../the-quest/TheQuestAppView';
import { TetrisAppView } from '../tetris/TetrisAppView';
import { APP_LIBRARY_ENTRIES, findReadyPlayAppEntryById } from './appsRegistry';
import { buildPlayAppPath } from '../../../../app/main/routes';
function normalizeReturnToPath(returnTo: string | null) {
if (!returnTo || !returnTo.startsWith('/') || returnTo.startsWith('//')) {
return null;
}
return returnTo;
}
export function AppsLibraryView() {
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [isCompactViewport, setIsCompactViewport] = useState(() =>
typeof window === 'undefined' ? false : window.matchMedia('(max-width: 768px)').matches,
);
const activeAppId = searchParams.get('app');
const launchContext = searchParams.get('launchContext') === 'embedded' ? 'embedded' : 'direct';
const returnTo = normalizeReturnToPath(searchParams.get('returnTo'));
const activeAppEntry = findReadyPlayAppEntryById(activeAppId);
const readyCount = useMemo(() => APP_LIBRARY_ENTRIES.filter((entry) => entry.isReady).length, []);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const mediaQuery = window.matchMedia('(max-width: 768px)');
const handleChange = () => {
setIsCompactViewport(mediaQuery.matches);
};
handleChange();
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, []);
const openApp = (appId: string) => {
const currentPath = `${location.pathname}${location.search}${location.hash}`;
setSearchParams(new URLSearchParams(buildPlayAppPath(appId, 'embedded', currentPath).split('?')[1] ?? ''));
};
const closeApp = () => {
if (returnTo) {
navigate(returnTo);
return;
}
const nextSearchParams = new URLSearchParams(searchParams);
nextSearchParams.delete('app');
nextSearchParams.delete('launchContext');
nextSearchParams.delete('returnTo');
setSearchParams(nextSearchParams);
};
if (activeAppEntry?.id === 'photoprism') {
return <PhotoPrismAppView onBack={closeApp} launchContext={launchContext} />;
}
if (activeAppEntry?.id === 'e-reader') {
return <EReaderAppView onBack={closeApp} launchContext={launchContext} />;
}
if (activeAppEntry?.id === 'photo-puzzle') {
return <PhotoPuzzleAppView onBack={closeApp} launchContext={launchContext} />;
}
if (activeAppEntry?.id === 'tetris') {
return <TetrisAppView onBack={closeApp} launchContext={launchContext} />;
}
if (activeAppEntry?.id === 'the-quest') {
return <TheQuestAppView onBack={closeApp} launchContext={launchContext} />;
}
return (
<section className="apps-library" data-testid="apps-library">
<header className="apps-library__topbar">
<div className="apps-library__title">
<strong> </strong>
<span>{APP_LIBRARY_ENTRIES.length}</span>
</div>
<Tag bordered={false} color="gold">
{readyCount}
</Tag>
</header>
<div className={`apps-library__shelf${isCompactViewport ? ' apps-library__shelf--compact' : ''}`}>
{APP_LIBRARY_ENTRIES.map((entry) => (
<button
key={entry.id}
type="button"
className={`apps-library__card ${entry.accentClassName}${entry.isReady ? ' apps-library__card--ready' : ''}`}
disabled={!entry.isReady}
data-testid={
entry.id === 'e-reader'
? 'apps-library-open-e-reader'
: entry.id === 'photoprism'
? 'apps-library-open-photoprism'
: entry.id === 'photo-puzzle'
? 'apps-library-open-photo-puzzle'
: entry.id === 'tetris'
? 'apps-library-open-tetris'
: entry.id === 'the-quest'
? 'apps-library-open-the-quest'
: undefined
}
onClick={() => openApp(entry.id)}
>
<span className="apps-library__icon">{entry.icon}</span>
<strong>{entry.name}</strong>
<span className="apps-library__meta">{entry.statusLabel}</span>
</button>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,114 @@
import {
AppstoreOutlined,
BookOutlined,
FileImageOutlined,
FireOutlined,
FundProjectionScreenOutlined,
PictureOutlined,
RocketOutlined,
SoundOutlined,
StarOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
export type PlayAppEnvironment = 'preview' | 'test' | 'prod';
export type PlayAppEntry = {
id: string;
name: string;
accentClassName: string;
statusLabel: string;
isReady: boolean;
icon: ReactNode;
supportedEnvironments?: PlayAppEnvironment[];
searchKeywords?: string[];
searchDescription?: string;
};
export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
{
id: 'e-reader',
name: 'E-Reader',
accentClassName: 'apps-library__card--reader',
statusLabel: '읽기',
isReady: true,
icon: <BookOutlined />,
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,
icon: <FileImageOutlined />,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
},
{
id: 'photo-puzzle',
name: '사진 퍼즐',
accentClassName: 'apps-library__card--puzzle',
statusLabel: '실행',
isReady: true,
icon: <PictureOutlined />,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
},
{
id: 'the-quest',
name: 'The Quest',
accentClassName: 'apps-library__card--the-quest',
statusLabel: '신규',
isReady: true,
icon: <ThunderboltOutlined />,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
},
{
id: 'tetris',
name: 'Tetris',
accentClassName: 'apps-library__card--tetris',
statusLabel: '실행',
isReady: true,
icon: <FundProjectionScreenOutlined />,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',
},
{ id: 'beat-lab', name: 'Beat Lab', accentClassName: 'apps-library__card--beat', statusLabel: '준비', isReady: false, icon: <SoundOutlined /> },
{ id: 'sticker-booth', name: 'Sticker Booth', accentClassName: 'apps-library__card--sticker', statusLabel: '준비', isReady: false, icon: <StarOutlined /> },
{ id: 'launch-note', name: 'Launch Note', accentClassName: 'apps-library__card--launch', statusLabel: '예정', isReady: false, icon: <RocketOutlined /> },
{ id: 'arcade-pack', name: 'Arcade Pack', accentClassName: 'apps-library__card--arcade', statusLabel: '예정', isReady: false, icon: <FireOutlined /> },
{ id: 'app-vault', name: 'App Vault', accentClassName: 'apps-library__card--vault', statusLabel: '테마', isReady: false, icon: <AppstoreOutlined /> },
];
export function getReadyPlayAppEntries() {
return APP_LIBRARY_ENTRIES.filter((entry) => entry.isReady);
}
export function getSupportedPlayAppEnvironments(entry: PlayAppEntry): PlayAppEnvironment[] {
if (entry.supportedEnvironments && entry.supportedEnvironments.length > 0) {
return entry.supportedEnvironments;
}
return ['preview'];
}
export function isPlayAppSupportedInEnvironment(entry: PlayAppEntry, environment: PlayAppEnvironment) {
return getSupportedPlayAppEnvironments(entry).includes(environment);
}
export function findReadyPlayAppEntryById(appId: string | null | undefined) {
if (!appId) {
return null;
}
return getReadyPlayAppEntries().find((entry) => entry.id === appId) ?? null;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
import { appendClientIdHeader } from '../../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
const WORK_SERVER_TIMEOUT_MS = 12_000;
export type EReaderExtractedArticle = {
title: string;
sourceLabel: string;
url: string;
lead: string;
body: string;
htmlBody?: string;
tags?: string[];
signals?: string[];
listedDate?: string;
publishedAt?: string;
};
export type EReaderLibraryArticle = {
id: string;
title: string;
sourceLabel: string;
url: string;
lead: string;
body: string;
htmlBody?: string;
tags?: string[];
signals?: string[];
listedDate?: string;
publishedAt?: string;
createdAt?: string;
updatedAt?: string;
};
export type EReaderNewsArticle = {
id: string;
title: string;
sourceLabel: string;
url: string;
lead: string;
body: string;
htmlBody?: string;
tags?: string[];
signals?: string[];
listedDate?: string;
publishedAt?: string;
createdAt?: string;
updatedAt?: string;
};
export type EReaderNewsSearchParams = {
keyword?: string;
topics?: Array<'politics' | 'economy' | 'society' | 'culture' | 'world' | 'it'>;
sources?: string[];
dateFrom: string;
dateTo: string;
limit?: number;
};
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveWorkServerFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
const isLocalWorkServerHost =
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalWorkServerHost) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
const WORK_SERVER_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
class EReaderApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'EReaderApiError';
this.status = status;
}
}
function parseJsonPayload<T>(text: string, fallbackMessage: string) {
if (!text.trim()) {
throw new EReaderApiError(fallbackMessage, 502);
}
try {
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof Error && /Unexpected non-whitespace character after JSON/i.test(error.message)) {
throw new EReaderApiError('뉴스 응답 형식이 올바르지 않습니다. 잠시 후 다시 시도해 주세요.', 502);
}
throw new EReaderApiError(fallbackMessage, 502);
}
}
async function requestOnce<T>(baseUrl: string, path: string, init: RequestInit, fallbackMessage: string): Promise<T> {
let response: Response;
try {
response = await fetch(`${baseUrl}${path}`, init);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new EReaderApiError('응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error;
}
const text = await response.text();
if (!response.ok) {
try {
const payload = JSON.parse(text) as { message?: string };
throw new EReaderApiError(payload.message || fallbackMessage, response.status);
} catch (error) {
if (error instanceof EReaderApiError) {
throw error;
}
throw new EReaderApiError(text || fallbackMessage, response.status);
}
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('application/json')) {
throw new EReaderApiError(fallbackMessage, 502);
}
return parseJsonPayload<T>(text, fallbackMessage);
}
async function requestReaderApi<T>(path: string, init: RequestInit, fallbackMessage: string) {
try {
return await requestOnce<T>(WORK_SERVER_BASE_URL, path, init, fallbackMessage);
} catch (error) {
const shouldRetryWithFallback =
WORK_SERVER_FALLBACK_BASE_URL &&
WORK_SERVER_FALLBACK_BASE_URL !== WORK_SERVER_BASE_URL &&
(error instanceof EReaderApiError
? error.status === 404 || error.status === 408 || error.status === 502
: false);
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(WORK_SERVER_FALLBACK_BASE_URL, path, init, fallbackMessage);
}
}
export async function extractReaderArticle(url: string) {
const headers = appendClientIdHeader();
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
try {
const params = new URLSearchParams({ url: url.trim() });
const payload = await requestReaderApi<{
ok: boolean;
item: EReaderExtractedArticle;
}>(`/reader/extract?${params.toString()}`, {
method: 'GET',
headers,
signal: controller.signal,
cache: 'no-store',
}, '원문 자동 가져오기에 실패했습니다.');
return payload.item;
} finally {
window.clearTimeout(timeoutId);
}
}
function buildReaderHeaders() {
const headers = appendClientIdHeader();
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
return headers;
}
export async function listReaderLibraryArticles() {
const headers = buildReaderHeaders();
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
try {
const payload = await requestReaderApi<{
ok: boolean;
items: EReaderLibraryArticle[];
}>('/reader/library', {
method: 'GET',
headers,
signal: controller.signal,
cache: 'no-store',
}, '서버 서가를 불러오지 못했습니다.');
return payload.items;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function saveReaderLibraryArticle(article: EReaderLibraryArticle) {
const headers = buildReaderHeaders();
headers.set('Content-Type', 'application/json');
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
try {
const payload = await requestReaderApi<{
ok: boolean;
item: EReaderLibraryArticle;
}>('/reader/library', {
method: 'POST',
headers,
signal: controller.signal,
cache: 'no-store',
body: JSON.stringify({ item: article }),
}, '서버 저장에 실패했습니다.');
return payload.item;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function searchReaderNews(params: EReaderNewsSearchParams) {
const headers = buildReaderHeaders();
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
try {
const query = new URLSearchParams({
dateFrom: params.dateFrom.trim(),
dateTo: params.dateTo.trim(),
});
if (params.keyword?.trim()) {
query.set('keyword', params.keyword.trim());
}
params.sources?.forEach((source) => {
const trimmedSource = source.trim();
if (trimmedSource) {
query.append('source', trimmedSource);
}
});
params.topics?.forEach((topic) => {
query.append('topic', topic);
});
if (typeof params.limit === 'number' && Number.isFinite(params.limit)) {
query.set('limit', String(Math.trunc(params.limit)));
}
const payload = await requestReaderApi<{
ok: boolean;
items: EReaderNewsArticle[];
}>(`/reader/news?${query.toString()}`, {
method: 'GET',
headers,
signal: controller.signal,
cache: 'no-store',
}, '뉴스 검색에 실패했습니다.');
return payload.items;
} finally {
window.clearTimeout(timeoutId);
}
}

View File

@@ -0,0 +1,277 @@
.photo-puzzle-app {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
gap: 10px;
height: 100%;
min-height: 0;
padding: clamp(10px, 1.5vw, 16px);
color: #f8fbff;
background:
radial-gradient(circle at top left, rgba(255, 185, 70, 0.24), transparent 26%),
radial-gradient(circle at top right, rgba(94, 183, 255, 0.22), transparent 30%),
linear-gradient(180deg, #121826 0%, #0a1020 100%);
border-radius: 20px;
overflow: hidden;
}
.photo-puzzle-app__topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.photo-puzzle-app__back {
width: auto;
color: #f8fbff;
padding-inline: 0;
}
.photo-puzzle-app__title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.photo-puzzle-app__title strong {
font-size: clamp(18px, 2vw, 22px);
}
.photo-puzzle-app__title span,
.photo-puzzle-app__statusline {
font-size: 12px;
color: rgba(248, 251, 255, 0.72);
}
.photo-puzzle-app__filters {
min-height: 0;
}
.photo-puzzle-app__statusline {
display: grid;
grid-template-columns: minmax(0, 1fr) repeat(3, auto);
gap: 8px;
align-items: center;
}
.photo-puzzle-app__statusline span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.photo-puzzle-app__board-shell {
display: grid;
place-items: center;
min-height: 0;
overflow: hidden;
}
.photo-puzzle-app__board {
position: relative;
display: grid;
gap: 4px;
min-height: 0;
padding: 4px;
background: rgba(4, 10, 18, 0.9);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
}
.photo-puzzle-app__tile {
position: relative;
aspect-ratio: 1;
border: 0;
border-radius: 2px;
overflow: hidden;
cursor: pointer;
background-color: #22304e;
box-shadow: none;
transition: opacity 160ms ease;
}
.photo-puzzle-app__tile:hover {
opacity: 0.94;
}
.photo-puzzle-app__tile em {
position: absolute;
left: 4px;
bottom: 4px;
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border-radius: 2px;
background: rgba(5, 11, 23, 0.72);
color: #fff;
font-style: normal;
font-size: 10px;
font-weight: 700;
}
.photo-puzzle-app__tile--blank {
background: rgba(255, 255, 255, 0.08);
cursor: default;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.photo-puzzle-app__tile--blank:hover {
opacity: 1;
}
.photo-puzzle-app__success {
position: absolute;
inset: 10px;
display: grid;
place-content: center;
gap: 8px;
text-align: center;
border-radius: 8px;
background: rgba(7, 14, 28, 0.82);
backdrop-filter: blur(4px);
}
.photo-puzzle-app__success .anticon {
font-size: 28px;
color: #ffd76f;
}
.photo-puzzle-app__bottom {
display: grid;
grid-template-rows: auto auto auto;
gap: 8px;
min-height: 0;
padding: 8px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.photo-puzzle-app__controls,
.photo-puzzle-app__actions,
.photo-puzzle-app__thumbs-header,
.photo-puzzle-app__thumbs-meta {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.photo-puzzle-app__controls {
justify-content: space-between;
}
.photo-puzzle-app__actions {
justify-content: flex-end;
}
.photo-puzzle-app__thumbs-header {
justify-content: space-between;
font-size: 12px;
color: rgba(248, 251, 255, 0.7);
}
.photo-puzzle-app__thumbs-complete {
color: #73ffa7;
}
.photo-puzzle-app__thumbs {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 6px;
min-height: 0;
}
.photo-puzzle-app__thumb {
display: grid;
gap: 4px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 14, 28, 0.78);
border-radius: 6px;
cursor: pointer;
overflow: hidden;
}
.photo-puzzle-app__thumb img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
}
.photo-puzzle-app__thumb span {
font-size: 10px;
color: rgba(248, 251, 255, 0.7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.photo-puzzle-app__thumb--active {
border-color: rgba(255, 206, 107, 0.72);
background: rgba(19, 29, 52, 0.96);
}
@media (max-width: 900px) {
.photo-puzzle-app__controls {
display: grid;
grid-template-columns: 1fr;
}
.photo-puzzle-app__actions {
justify-content: stretch;
}
.photo-puzzle-app__actions .ant-btn {
flex: 1 1 0;
}
}
@media (max-width: 720px) {
.photo-puzzle-app {
padding: 10px;
}
.photo-puzzle-app__statusline {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.photo-puzzle-app__statusline span:first-child {
grid-column: 1 / -1;
}
.photo-puzzle-app__thumbs {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.photo-puzzle-app__topbar {
gap: 6px;
}
.photo-puzzle-app__title strong {
font-size: 16px;
}
.photo-puzzle-app__bottom {
padding: 6px;
}
.photo-puzzle-app__thumbs {
gap: 4px;
}
.photo-puzzle-app__thumb {
padding: 3px;
}
.photo-puzzle-app__thumb span {
display: none;
}
}

View File

@@ -0,0 +1,652 @@
import {
ArrowLeftOutlined,
CheckCircleFilled,
CloseOutlined,
TrophyOutlined,
} from '@ant-design/icons';
import { Button, Progress, Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import './photoPuzzleAppView.css';
import { PHOTO_PUZZLE_CATEGORY_LABELS, PHOTO_PUZZLE_IMAGES, type PhotoPuzzleCategory } from './photoPuzzleCatalog';
type PhotoPuzzleAppViewProps = {
onBack: () => void;
launchContext?: 'direct' | 'embedded';
};
type PuzzleLevel = '3x3' | '4x4' | '5x5' | '6x6';
type PuzzleShape = {
cols: number;
rows: number;
label: string;
};
type MobileFlowStep = 'category' | 'photo' | 'play';
const PUZZLE_LEVELS: Record<PuzzleLevel, PuzzleShape> = {
'3x3': { cols: 3, rows: 3, label: '3 x 3' },
'4x4': { cols: 4, rows: 4, label: '4 x 4' },
'5x5': { cols: 5, rows: 5, label: '5 x 5' },
'6x6': { cols: 6, rows: 6, label: '6 x 6' },
};
function buildSolvedBoard(tileCount: number) {
return Array.from({ length: tileCount }, (_, index) => index);
}
function isSolved(board: number[]) {
return board.every((value, index) => value === index);
}
function isAdjacent(leftIndex: number, rightIndex: number, cols: number) {
const leftRow = Math.floor(leftIndex / cols);
const leftCol = leftIndex % cols;
const rightRow = Math.floor(rightIndex / cols);
const rightCol = rightIndex % cols;
return Math.abs(leftRow - rightRow) + Math.abs(leftCol - rightCol) === 1;
}
function moveTile(board: number[], tileIndex: number, cols: number) {
const blankValue = board.length - 1;
const blankIndex = board.indexOf(blankValue);
if (!isAdjacent(tileIndex, blankIndex, cols)) {
return board;
}
const nextBoard = [...board];
[nextBoard[tileIndex], nextBoard[blankIndex]] = [nextBoard[blankIndex], nextBoard[tileIndex]];
return nextBoard;
}
function shuffleBoard(level: PuzzleLevel) {
const { cols, rows } = PUZZLE_LEVELS[level];
const tileCount = cols * rows;
let board = buildSolvedBoard(tileCount);
let previousBlankIndex = -1;
for (let step = 0; step < tileCount * 36; step += 1) {
const blankIndex = board.indexOf(tileCount - 1);
const candidates = board
.map((_, index) => index)
.filter((index) => index !== previousBlankIndex && isAdjacent(index, blankIndex, cols));
const nextTileIndex = candidates[Math.floor(Math.random() * candidates.length)] ?? candidates[0];
previousBlankIndex = blankIndex;
board = moveTile(board, nextTileIndex, cols);
}
return isSolved(board) ? moveTile(board, tileCount - 2, cols) : board;
}
function getTileBackgroundPosition(tileValue: number, cols: number, rows: number) {
const tileRow = Math.floor(tileValue / cols);
const tileCol = tileValue % cols;
const x = cols === 1 ? 0 : (tileCol / (cols - 1)) * 100;
const y = rows === 1 ? 0 : (tileRow / (rows - 1)) * 100;
return `${x}% ${y}%`;
}
function toCssBackgroundImage(imageUrl: string) {
return `url("${imageUrl.replace(/"/g, '\\"')}")`;
}
function getTileScale(level: PuzzleLevel) {
if (level === '6x6') {
return 0.74;
}
if (level === '5x5') {
return 0.82;
}
if (level === '4x4') {
return 0.9;
}
return 1;
}
export function PhotoPuzzleAppView({ onBack, launchContext = 'direct' }: PhotoPuzzleAppViewProps) {
const [category, setCategory] = useState<'all' | PhotoPuzzleCategory>('all');
const [level, setLevel] = useState<PuzzleLevel>('4x4');
const [selectedImageId, setSelectedImageId] = useState(PHOTO_PUZZLE_IMAGES[0]?.id ?? '');
const [board, setBoard] = useState<number[]>(() => shuffleBoard('4x4'));
const [moveCount, setMoveCount] = useState(0);
const [completedAtMoves, setCompletedAtMoves] = useState<number | null>(null);
const [galleryPage, setGalleryPage] = useState(0);
const [isCompactViewport, setIsCompactViewport] = useState(() =>
typeof window === 'undefined' ? false : window.matchMedia('(max-width: 720px)').matches,
);
const [isDesktopGalleryOpen, setIsDesktopGalleryOpen] = useState(false);
const [mobileFlowStep, setMobileFlowStep] = useState<MobileFlowStep>('play');
const filteredImages = useMemo(
() => PHOTO_PUZZLE_IMAGES.filter((image) => category === 'all' || image.category === category),
[category],
);
const selectedImage = useMemo(
() => filteredImages.find((image) => image.id === selectedImageId) ?? PHOTO_PUZZLE_IMAGES.find((image) => image.id === selectedImageId) ?? PHOTO_PUZZLE_IMAGES[0],
[filteredImages, selectedImageId],
);
const { cols, rows, label } = PUZZLE_LEVELS[level];
const tileCount = cols * rows;
const blankValue = tileCount - 1;
const completedTileCount = board.filter((value, index) => value === index).length;
const solvedPercent = Math.round((completedTileCount / tileCount) * 100);
const galleryPageSize = 4;
const galleryPageCount = Math.max(1, Math.ceil(filteredImages.length / galleryPageSize));
const pagedImages = filteredImages.slice(galleryPage * galleryPageSize, galleryPage * galleryPageSize + galleryPageSize);
const boardScale = getTileScale(level);
const showDesktopGallery = !isCompactViewport && isDesktopGalleryOpen;
const showCategorySelector = isCompactViewport && mobileFlowStep === 'category';
const showPhotoSelector = isCompactViewport && mobileFlowStep === 'photo';
const showPlayStage = !isCompactViewport || mobileFlowStep === 'play';
const showMobileSelectionBar = isCompactViewport;
const showMobileProgress = isCompactViewport && showPlayStage;
const isEmbeddedLaunch = launchContext === 'embedded';
useEffect(() => {
if (!selectedImage) {
return;
}
if (!filteredImages.some((image) => image.id === selectedImage.id)) {
setSelectedImageId(filteredImages[0]?.id ?? selectedImage.id);
}
}, [filteredImages, selectedImage]);
useEffect(() => {
setBoard(shuffleBoard(level));
setMoveCount(0);
setCompletedAtMoves(null);
}, [level, selectedImageId]);
useEffect(() => {
const selectedIndex = filteredImages.findIndex((image) => image.id === selectedImage?.id);
if (selectedIndex >= 0) {
setGalleryPage(Math.floor(selectedIndex / galleryPageSize));
return;
}
setGalleryPage(0);
}, [filteredImages, selectedImage]);
useEffect(() => {
setGalleryPage((currentPage) => Math.min(currentPage, galleryPageCount - 1));
}, [galleryPageCount]);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const mediaQuery = window.matchMedia('(max-width: 720px)');
const handleChange = () => {
setIsCompactViewport(mediaQuery.matches);
};
handleChange();
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, []);
useEffect(() => {
if (!isCompactViewport) {
setMobileFlowStep('play');
return;
}
setMobileFlowStep((currentStep) => currentStep);
}, [isCompactViewport]);
useEffect(() => {
if (isCompactViewport) {
setIsDesktopGalleryOpen(false);
}
}, [isCompactViewport]);
const handleTileClick = (tileIndex: number) => {
if (completedAtMoves !== null) {
return;
}
const nextBoard = moveTile(board, tileIndex, cols);
if (nextBoard === board) {
return;
}
const nextMoveCount = moveCount + 1;
setBoard(nextBoard);
setMoveCount(nextMoveCount);
if (isSolved(nextBoard)) {
setCompletedAtMoves(nextMoveCount);
}
};
const handleShuffle = () => {
setBoard(shuffleBoard(level));
setMoveCount(0);
setCompletedAtMoves(null);
};
const handleReset = () => {
setBoard(buildSolvedBoard(tileCount));
setMoveCount(0);
setCompletedAtMoves(null);
};
const handleRandomImage = () => {
const candidates = filteredImages.length ? filteredImages : PHOTO_PUZZLE_IMAGES;
const nextImage = candidates[Math.floor(Math.random() * candidates.length)] ?? PHOTO_PUZZLE_IMAGES[0];
if (nextImage) {
setSelectedImageId(nextImage.id);
if (!isCompactViewport) {
setIsDesktopGalleryOpen(false);
}
if (isCompactViewport) {
setMobileFlowStep('play');
}
}
};
const handleCategorySelect = (value: 'all' | PhotoPuzzleCategory) => {
setCategory(value);
setGalleryPage(0);
if (isCompactViewport) {
setMobileFlowStep('photo');
}
};
const handleImageSelect = (imageId: string) => {
setSelectedImageId(imageId);
if (isCompactViewport) {
setMobileFlowStep('play');
return;
}
setIsDesktopGalleryOpen(false);
};
if (!selectedImage) {
return null;
}
return (
<section className="photo-puzzle-app" data-testid="photo-puzzle-app" data-level={level}>
<header className="photo-puzzle-app__hero">
<div className="photo-puzzle-app__hero-copy">
<div className="photo-puzzle-app__hero-nav">
<Button type="text" icon={<ArrowLeftOutlined />} className="photo-puzzle-app__back" onClick={onBack}>
</Button>
{isEmbeddedLaunch ? (
<Button type="text" icon={<CloseOutlined />} className="photo-puzzle-app__exit" onClick={onBack}>
</Button>
) : null}
</div>
<div className="photo-puzzle-app__eyebrow">
<Tag bordered={false} color="geekblue">
Photo Puzzle
</Tag>
<span>Play / Apps / Apps</span>
</div>
</div>
<div className="photo-puzzle-app__hero-stats">
<div className="photo-puzzle-app__hero-stat photo-puzzle-app__hero-stat--photo">
<span></span>
<strong>{PHOTO_PUZZLE_IMAGES.length}</strong>
</div>
<div className="photo-puzzle-app__hero-stat photo-puzzle-app__hero-stat--level">
<span></span>
<strong>{label}</strong>
</div>
<div className="photo-puzzle-app__hero-stat photo-puzzle-app__hero-stat--moves">
<span></span>
<strong>{moveCount}</strong>
</div>
</div>
</header>
<div className={`photo-puzzle-app__body${showDesktopGallery ? ' photo-puzzle-app__body--with-gallery' : ''}`}>
<div className={`photo-puzzle-app__board-panel${showPlayStage ? ' photo-puzzle-app__board-panel--play' : ''}`}>
{showMobileSelectionBar ? (
<div className="photo-puzzle-app__mobile-selection-bar">
<Button
size="small"
type="text"
icon={<ArrowLeftOutlined />}
className="photo-puzzle-app__mobile-back"
onClick={onBack}
aria-label="앱 보관함으로 돌아가기"
/>
<img src={selectedImage.imageUrl} alt={selectedImage.title} className="photo-puzzle-app__mobile-selection-thumb" />
<div className="photo-puzzle-app__mobile-selection-actions">
<Button size="small" onClick={() => setMobileFlowStep('photo')}>
</Button>
<Button size="small" onClick={() => setMobileFlowStep('category')}>
</Button>
{showPlayStage ? (
<Button size="small" onClick={handleShuffle} data-testid="photo-puzzle-shuffle">
</Button>
) : (
<Button size="small" onClick={() => setMobileFlowStep('play')}>
</Button>
)}
</div>
</div>
) : null}
{showCategorySelector ? (
<div className="photo-puzzle-app__category-panel">
<div className="photo-puzzle-app__category-group" role="group" aria-label="사진 카테고리 선택">
{(Object.keys(PHOTO_PUZZLE_CATEGORY_LABELS) as Array<'all' | PhotoPuzzleCategory>).map((value) => (
<button
key={value}
type="button"
className={`photo-puzzle-app__chip photo-puzzle-app__chip--small${value === category ? ' photo-puzzle-app__chip--active' : ''}`}
onClick={() => handleCategorySelect(value)}
>
{PHOTO_PUZZLE_CATEGORY_LABELS[value]}
</button>
))}
</div>
</div>
) : null}
{showPhotoSelector ? (
<div className="photo-puzzle-app__mobile-photo-panel">
<div className="photo-puzzle-app__gallery-grid">
{pagedImages.map((image) => {
const isActive = image.id === selectedImage.id;
return (
<button
key={image.id}
type="button"
className={`photo-puzzle-app__thumb${isActive ? ' photo-puzzle-app__thumb--active' : ''}`}
onClick={() => handleImageSelect(image.id)}
data-testid={`photo-puzzle-image-${image.id}`}
>
<img src={image.imageUrl} alt={image.title} />
{isCompactViewport ? null : (
<span className="photo-puzzle-app__thumb-meta">
<Tag bordered={false}>{image.badge}</Tag>
<strong>{image.title}</strong>
</span>
)}
</button>
);
})}
</div>
<div className="photo-puzzle-app__gallery-pager">
<div className="photo-puzzle-app__gallery-pager-count">
<strong>
{galleryPage + 1} / {galleryPageCount}
</strong>
</div>
<div className="photo-puzzle-app__gallery-pager-actions">
<Button disabled={galleryPage === 0} onClick={() => setGalleryPage((currentPage) => Math.max(0, currentPage - 1))}>
</Button>
<Button disabled={galleryPage >= galleryPageCount - 1} onClick={() => setGalleryPage((currentPage) => Math.min(galleryPageCount - 1, currentPage + 1))}>
</Button>
</div>
</div>
</div>
) : null}
{showPlayStage ? (
<>
{showMobileProgress ? null : (
<>
<div className="photo-puzzle-app__control-strip">
<div className="photo-puzzle-app__level-group" role="group" aria-label="퍼즐 크기 선택">
{(Object.keys(PUZZLE_LEVELS) as PuzzleLevel[]).map((value) => (
<button
key={value}
type="button"
className={`photo-puzzle-app__chip${value === level ? ' photo-puzzle-app__chip--active' : ''}`}
onClick={() => setLevel(value)}
data-testid={`photo-puzzle-level-${value}`}
>
{PUZZLE_LEVELS[value].label}
</button>
))}
</div>
<div className="photo-puzzle-app__toolbar-actions">
<Button onClick={() => setIsDesktopGalleryOpen((currentValue) => !currentValue)}>
{showDesktopGallery ? '1 닫기' : '1 사진'}
</Button>
<Button onClick={handleRandomImage} data-testid="photo-puzzle-random-image">
2
</Button>
<Button onClick={handleShuffle} data-testid="photo-puzzle-shuffle">
3
</Button>
<Button onClick={handleReset} data-testid="photo-puzzle-reset">
4
</Button>
</div>
</div>
<div className="photo-puzzle-app__status-row">
<div className="photo-puzzle-app__status-card">
<span></span>
<strong>{solvedPercent}%</strong>
<Progress percent={solvedPercent} showInfo={false} strokeColor="#1d4ed8" trailColor="rgba(148, 163, 184, 0.18)" />
</div>
<div className="photo-puzzle-app__status-card">
<span> </span>
<strong>{selectedImage.title}</strong>
<Tag bordered={false}>{selectedImage.badge}</Tag>
</div>
<div className="photo-puzzle-app__status-card">
<span> </span>
<strong>{completedAtMoves === null ? '진행 중' : '완료'}</strong>
{completedAtMoves === null ? (
<Tag bordered={false} color="processing">
{moveCount}
</Tag>
) : (
<Tag bordered={false} color="success" icon={<CheckCircleFilled />}>
{completedAtMoves}
</Tag>
)}
</div>
</div>
</>
)}
<div className="photo-puzzle-app__board-stage">
<div className="photo-puzzle-app__board-frame" style={{ ['--photo-puzzle-board-scale' as string]: `${boardScale}` }}>
<div
className="photo-puzzle-app__board"
data-testid="photo-puzzle-board"
data-board-signature={board.join(',')}
data-board-size={tileCount}
data-move-count={moveCount}
style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
>
{board.map((tileValue, tileIndex) => {
const isBlank = tileValue === blankValue;
return (
<button
key={`${tileValue}-${tileIndex}`}
type="button"
className={`photo-puzzle-app__tile${isBlank ? ' photo-puzzle-app__tile--blank' : ''}`}
disabled={isBlank}
data-testid={isBlank ? 'photo-puzzle-blank-tile' : `photo-puzzle-tile-${tileValue}`}
data-tile-index={tileIndex}
data-tile-value={tileValue}
onClick={() => handleTileClick(tileIndex)}
aria-label={isBlank ? '빈 칸' : '퍼즐 조각 이동'}
style={
isBlank
? undefined
: {
backgroundImage: toCssBackgroundImage(selectedImage.imageUrl),
backgroundSize: `${cols * 100}% ${rows * 100}%`,
backgroundPosition: getTileBackgroundPosition(tileValue, cols, rows),
}
}
/>
);
})}
{completedAtMoves === null ? null : (
<div className="photo-puzzle-app__success">
<TrophyOutlined />
<strong> </strong>
<span>{completedAtMoves} .</span>
<Button type="primary" onClick={handleShuffle}>
</Button>
</div>
)}
</div>
</div>
</div>
{isCompactViewport ? (
<>
<div className="photo-puzzle-app__mobile-progress" data-testid="photo-puzzle-mobile-progress">
<div className="photo-puzzle-app__mobile-progress-copy">
<span></span>
<strong>{solvedPercent}%</strong>
</div>
<Progress
percent={solvedPercent}
showInfo={false}
size="small"
strokeColor="#1d4ed8"
trailColor="rgba(148, 163, 184, 0.22)"
/>
{completedAtMoves === null ? (
<Tag bordered={false} color="processing">
</Tag>
) : (
<Tag bordered={false} color="success" icon={<CheckCircleFilled />}>
{completedAtMoves}
</Tag>
)}
</div>
<div className="photo-puzzle-app__level-group photo-puzzle-app__level-group--mobile" role="group" aria-label="퍼즐 크기 선택">
{(Object.keys(PUZZLE_LEVELS) as PuzzleLevel[]).map((value) => (
<button
key={value}
type="button"
className={`photo-puzzle-app__chip photo-puzzle-app__chip--small${value === level ? ' photo-puzzle-app__chip--active' : ''}`}
onClick={() => setLevel(value)}
data-testid={`photo-puzzle-level-${value}`}
>
{PUZZLE_LEVELS[value].label}
</button>
))}
</div>
<div className="photo-puzzle-app__mobile-play-actions">
<Button onClick={handleReset} data-testid="photo-puzzle-reset">
</Button>
<Button onClick={handleRandomImage} data-testid="photo-puzzle-random-image">
</Button>
</div>
</>
) : null}
</>
) : null}
</div>
{showDesktopGallery ? (
<aside className="photo-puzzle-app__gallery-panel">
<div className="photo-puzzle-app__gallery-top">
<div className="photo-puzzle-app__category-group" role="group" aria-label="사진 카테고리 선택">
{(Object.keys(PHOTO_PUZZLE_CATEGORY_LABELS) as Array<'all' | PhotoPuzzleCategory>).map((value) => (
<button
key={value}
type="button"
className={`photo-puzzle-app__chip photo-puzzle-app__chip--small${value === category ? ' photo-puzzle-app__chip--active' : ''}`}
onClick={() => handleCategorySelect(value)}
>
{PHOTO_PUZZLE_CATEGORY_LABELS[value]}
</button>
))}
</div>
</div>
<div className="photo-puzzle-app__selected-preview">
<img src={selectedImage.imageUrl} alt={selectedImage.title} />
<div className="photo-puzzle-app__selected-preview-copy">
<Tag bordered={false}>{selectedImage.badge}</Tag>
<strong>{selectedImage.title}</strong>
</div>
</div>
<div className="photo-puzzle-app__gallery-grid">
{pagedImages.map((image) => {
const isActive = image.id === selectedImage.id;
return (
<button
key={image.id}
type="button"
className={`photo-puzzle-app__thumb${isActive ? ' photo-puzzle-app__thumb--active' : ''}`}
onClick={() => handleImageSelect(image.id)}
data-testid={`photo-puzzle-image-${image.id}`}
>
<img src={image.imageUrl} alt={image.title} />
{isCompactViewport ? null : (
<span className="photo-puzzle-app__thumb-meta">
<Tag bordered={false}>{image.badge}</Tag>
<strong>{image.title}</strong>
</span>
)}
</button>
);
})}
</div>
<div className="photo-puzzle-app__gallery-pager">
<div className="photo-puzzle-app__gallery-pager-count">
<strong>
{galleryPage + 1} / {galleryPageCount}
</strong>
</div>
<div className="photo-puzzle-app__gallery-pager-actions">
<Button disabled={galleryPage === 0} onClick={() => setGalleryPage((currentPage) => Math.max(0, currentPage - 1))}>
</Button>
<Button disabled={galleryPage >= galleryPageCount - 1} onClick={() => setGalleryPage((currentPage) => Math.min(galleryPageCount - 1, currentPage + 1))}>
</Button>
</div>
</div>
</aside>
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,800 @@
.photo-puzzle-app {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
height: 100%;
min-height: 0;
padding: clamp(10px, 1.5vh, 16px);
overflow: hidden;
color: #0f172a;
background:
radial-gradient(
circle at top left,
rgba(148, 163, 184, 0.18),
transparent 22%
),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
}
.photo-puzzle-app__hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.photo-puzzle-app__hero-copy {
display: grid;
gap: 6px;
min-width: 0;
}
.photo-puzzle-app__hero-nav {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.photo-puzzle-app__back {
width: fit-content;
padding-inline: 0;
color: #334155;
}
.photo-puzzle-app__exit {
height: 32px;
padding-inline: 12px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.94);
color: #334155;
}
.photo-puzzle-app__eyebrow {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: #64748b;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.photo-puzzle-app__hero-stats {
display: flex;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 12px;
}
.photo-puzzle-app__status-card,
.photo-puzzle-app__gallery-panel,
.photo-puzzle-app__selected-preview,
.photo-puzzle-app__gallery-pager {
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.9);
}
.photo-puzzle-app__hero-stats div {
display: grid;
gap: 1px;
padding: 0;
border: 0;
background: transparent;
border-radius: 0;
justify-items: end;
}
.photo-puzzle-app__hero-stat {
min-width: 0;
white-space: nowrap;
}
.photo-puzzle-app__hero-stat--photo {
justify-content: center;
text-align: center;
}
.photo-puzzle-app__hero-stat span {
color: #64748b;
font-size: 10px;
line-height: 1.1;
letter-spacing: 0.02em;
}
.photo-puzzle-app__status-card span,
.photo-puzzle-app__gallery-heading span,
.photo-puzzle-app__gallery-pager span {
color: #64748b;
font-size: 11px;
}
.photo-puzzle-app__hero-stats strong,
.photo-puzzle-app__status-card strong,
.photo-puzzle-app__gallery-heading strong,
.photo-puzzle-app__gallery-pager strong {
color: #0f172a;
font-size: 15px;
font-weight: 600;
}
.photo-puzzle-app__body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
min-height: 0;
}
.photo-puzzle-app__body--with-gallery {
grid-template-columns: minmax(0, 1fr) minmax(290px, 340px);
}
.photo-puzzle-app__board-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 10px;
min-height: 0;
}
.photo-puzzle-app__board-panel--play {
height: 100%;
}
.photo-puzzle-app__category-panel,
.photo-puzzle-app__mobile-photo-panel {
display: grid;
gap: 8px;
padding: 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.9);
}
.photo-puzzle-app__section-heading {
display: grid;
gap: 2px;
}
.photo-puzzle-app__section-heading span {
color: #64748b;
font-size: 11px;
}
.photo-puzzle-app__section-heading strong {
color: #0f172a;
font-size: 14px;
}
.photo-puzzle-app__mobile-selection-bar {
display: grid;
grid-template-columns: auto 44px minmax(0, 1fr);
gap: 6px;
align-items: center;
padding: 0;
border: 0;
background: transparent;
}
.photo-puzzle-app__mobile-back {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
border-radius: 999px;
color: #334155;
}
.photo-puzzle-app__mobile-selection-thumb {
width: 44px;
height: 56px;
display: block;
object-fit: cover;
object-position: center top;
background: #e2e8f0;
}
.photo-puzzle-app__mobile-selection-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
min-width: 0;
}
.photo-puzzle-app__mobile-selection-actions {
justify-content: stretch;
align-items: center;
}
.photo-puzzle-app__mobile-selection-actions .ant-btn,
.photo-puzzle-app__gallery-pager-actions .ant-btn {
height: 30px;
padding-inline: 10px;
border-radius: 999px;
border-color: rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.94);
color: #334155;
font-size: 12px;
font-weight: 500;
box-shadow: none;
}
.photo-puzzle-app__gallery-pager-count {
display: flex;
align-items: center;
justify-content: center;
}
.photo-puzzle-app__control-strip {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.photo-puzzle-app__level-group,
.photo-puzzle-app__category-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.photo-puzzle-app__chip {
appearance: none;
border: 1px solid rgba(148, 163, 184, 0.24);
background: rgba(255, 255, 255, 0.78);
color: #334155;
min-height: 34px;
padding: 0 12px;
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.photo-puzzle-app__chip--small {
min-height: 30px;
padding-inline: 10px;
font-size: 11px;
}
.photo-puzzle-app__chip--active {
border-color: #1d4ed8;
background: #dbeafe;
color: #1d4ed8;
}
.photo-puzzle-app__toolbar-actions {
display: flex;
gap: 6px;
}
.photo-puzzle-app__toolbar-actions .ant-btn {
white-space: nowrap;
}
.photo-puzzle-app__status-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.photo-puzzle-app__status-row--mobile {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.photo-puzzle-app__status-card {
display: grid;
gap: 6px;
min-height: 0;
padding: 10px 12px;
}
.photo-puzzle-app__mobile-progress {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 2px 0 0;
}
.photo-puzzle-app__mobile-progress-copy {
display: grid;
gap: 1px;
}
.photo-puzzle-app__mobile-progress-copy span {
color: #64748b;
font-size: 10px;
line-height: 1.1;
}
.photo-puzzle-app__mobile-progress-copy strong {
color: #0f172a;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
}
.photo-puzzle-app__board-stage {
display: grid;
place-items: center;
align-content: center;
min-height: 0;
overflow: hidden;
}
.photo-puzzle-app__board-frame {
width: min(100%, calc(100dvh * var(--photo-puzzle-board-scale, 0.9)));
max-width: min(100%, 740px);
max-height: 100%;
aspect-ratio: 1;
display: grid;
place-items: center;
min-height: 0;
}
.photo-puzzle-app__board {
position: relative;
display: grid;
width: 100%;
height: 100%;
gap: 1px;
padding: 1px;
background: #cbd5e1;
min-height: 0;
}
.photo-puzzle-app__tile {
position: relative;
border: 0;
border-radius: 0;
overflow: hidden;
background-color: #e2e8f0;
background-repeat: no-repeat;
cursor: pointer;
transition:
filter 120ms ease,
transform 120ms ease;
}
.photo-puzzle-app__tile:hover {
filter: brightness(1.05);
transform: translateZ(0);
}
.photo-puzzle-app__tile--blank {
background:
linear-gradient(135deg, rgba(203, 213, 225, 0.9), rgba(226, 232, 240, 0.7)),
#e2e8f0;
cursor: default;
}
.photo-puzzle-app__tile--blank:hover {
filter: none;
}
.photo-puzzle-app__success {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 8px;
text-align: center;
background: rgba(248, 250, 252, 0.94);
color: #0f172a;
}
.photo-puzzle-app__success .anticon {
font-size: 40px;
color: #1d4ed8;
}
.photo-puzzle-app__gallery-panel {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
gap: 10px;
min-height: 0;
padding: 10px;
overflow: hidden;
}
.photo-puzzle-app__gallery-top,
.photo-puzzle-app__gallery-heading,
.photo-puzzle-app__selected-preview-copy,
.photo-puzzle-app__thumb-meta,
.photo-puzzle-app__gallery-pager div:first-child {
display: grid;
gap: 6px;
}
.photo-puzzle-app__selected-preview {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 10px;
min-height: 0;
overflow: hidden;
}
.photo-puzzle-app__selected-preview--mobile {
grid-template-columns: minmax(0, 1fr);
}
.photo-puzzle-app__selected-preview img,
.photo-puzzle-app__thumb img {
width: 100%;
height: 100%;
display: block;
aspect-ratio: 4 / 5;
object-fit: cover;
object-position: center top;
background: #e2e8f0;
}
.photo-puzzle-app__selected-preview--mobile img {
max-height: min(44dvh, 360px);
}
.photo-puzzle-app__selected-preview-copy {
align-content: center;
min-width: 0;
padding: 10px 10px 10px 0;
}
.photo-puzzle-app__selected-preview--mobile
.photo-puzzle-app__selected-preview-copy {
padding: 0 10px 10px;
}
.photo-puzzle-app__selected-preview-copy strong {
font-size: 14px;
line-height: 1.4;
}
.photo-puzzle-app__selected-preview-copy span {
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.photo-puzzle-app__mobile-play-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.photo-puzzle-app__level-group--mobile {
justify-content: space-between;
}
.photo-puzzle-app__gallery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
min-height: 0;
}
.photo-puzzle-app__thumb {
display: grid;
grid-template-rows: auto minmax(34px, auto);
gap: 6px;
align-content: start;
min-height: 0;
height: 100%;
padding: 6px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: #fff;
cursor: pointer;
overflow: hidden;
text-align: left;
}
.photo-puzzle-app__thumb--active {
border-color: #1d4ed8;
background: #eff6ff;
}
.photo-puzzle-app__thumb-meta {
align-content: start;
min-height: 34px;
}
.photo-puzzle-app__thumb-meta strong {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
color: #0f172a;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
}
.photo-puzzle-app__gallery-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 0;
padding: 8px 10px;
}
.photo-puzzle-app__gallery-pager-actions {
display: flex;
gap: 6px;
}
@media (max-width: 1200px) {
.photo-puzzle-app__body--with-gallery {
grid-template-columns: minmax(0, 1fr) 300px;
}
.photo-puzzle-app__toolbar-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
.photo-puzzle-app__hero {
grid-template-columns: 1fr;
align-items: start;
}
.photo-puzzle-app__hero-stats,
.photo-puzzle-app__status-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.photo-puzzle-app__body {
grid-template-columns: 1fr;
}
.photo-puzzle-app__gallery-panel {
grid-template-rows: auto auto auto auto auto;
}
.photo-puzzle-app__selected-preview {
grid-template-columns: 88px minmax(0, 1fr);
}
.photo-puzzle-app__gallery-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.photo-puzzle-app__thumb-meta strong {
-webkit-line-clamp: 1;
}
}
@media (max-width: 720px) {
.photo-puzzle-app {
padding: 8px;
gap: 8px;
}
.photo-puzzle-app__board-panel--play {
grid-template-rows: auto minmax(0, 1fr) auto auto auto;
}
.photo-puzzle-app__hero {
grid-template-columns: auto minmax(0, 1fr);
gap: 4px;
align-items: start;
}
.photo-puzzle-app__hero-copy {
display: grid;
justify-items: start;
align-content: start;
gap: 2px;
}
.photo-puzzle-app__eyebrow {
display: none;
}
.photo-puzzle-app__back {
min-width: 30px;
min-height: 30px;
font-size: 12px;
}
.photo-puzzle-app__back span {
display: none;
}
.photo-puzzle-app__control-strip,
.photo-puzzle-app__gallery-pager {
grid-template-columns: 1fr;
display: grid;
}
.photo-puzzle-app__toolbar-actions,
.photo-puzzle-app__gallery-pager-actions {
width: 100%;
}
.photo-puzzle-app__selected-preview--mobile img {
max-height: min(42dvh, 320px);
}
.photo-puzzle-app__toolbar-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.photo-puzzle-app__toolbar-actions .ant-btn,
.photo-puzzle-app__gallery-pager-actions .ant-btn {
flex: 1 1 0;
}
.photo-puzzle-app__hero-stats {
min-width: 0;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 6px;
}
.photo-puzzle-app__hero-stat--level {
order: 1;
}
.photo-puzzle-app__hero-stat--photo {
order: 2;
}
.photo-puzzle-app__hero-stat--moves {
order: 3;
}
.photo-puzzle-app__status-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.photo-puzzle-app__hero-stats div {
min-width: 0;
flex: 0 1 auto;
gap: 1px;
padding: 4px 0;
justify-items: end;
}
.photo-puzzle-app__hero-stats strong {
font-size: 13px;
font-weight: 500;
}
.photo-puzzle-app__hero-stat span {
font-size: 9px;
}
.photo-puzzle-app__status-card .ant-progress {
display: none;
}
.photo-puzzle-app__mobile-progress {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
}
.photo-puzzle-app__board-frame {
width: min(100%, calc(100dvh * var(--photo-puzzle-board-scale, 0.78)));
max-width: 100%;
}
.photo-puzzle-app__gallery-panel {
gap: 8px;
padding: 8px;
}
.photo-puzzle-app__gallery-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.photo-puzzle-app__category-panel,
.photo-puzzle-app__mobile-photo-panel {
gap: 6px;
padding: 0;
border: 0;
background: transparent;
}
.photo-puzzle-app__mobile-photo-panel .photo-puzzle-app__gallery-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.photo-puzzle-app__thumb {
padding: 2px;
gap: 0;
grid-template-rows: auto;
}
.photo-puzzle-app__thumb-meta .ant-tag {
display: none;
}
.photo-puzzle-app__thumb-meta strong {
display: none;
}
.photo-puzzle-app__mobile-selection-bar {
grid-template-columns: auto 40px minmax(0, 1fr);
align-items: center;
}
.photo-puzzle-app__mobile-back {
width: 30px;
min-width: 30px;
height: 30px;
}
.photo-puzzle-app__mobile-selection-thumb {
width: 40px;
height: 52px;
}
.photo-puzzle-app__mobile-selection-actions {
grid-template-columns: repeat(3, minmax(0, 1fr));
justify-content: stretch;
gap: 4px;
}
.photo-puzzle-app__mobile-selection-actions .ant-btn,
.photo-puzzle-app__gallery-pager-actions .ant-btn {
width: 100%;
padding-inline: 0;
font-size: 10px;
}
.photo-puzzle-app__gallery-pager-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
display: grid;
}
.photo-puzzle-app__gallery-pager {
padding: 2px 0 0;
border: 0;
background: transparent;
}
.photo-puzzle-app__gallery-pager div:first-child {
gap: 0;
}
.photo-puzzle-app__gallery-pager strong {
font-size: 12px;
font-weight: 500;
}
.photo-puzzle-app__mobile-progress {
grid-template-columns: 1fr;
justify-items: stretch;
gap: 6px;
padding-top: 0;
}
.photo-puzzle-app__mobile-progress-copy {
grid-auto-flow: column;
justify-content: space-between;
align-items: end;
}
.photo-puzzle-app__mobile-progress .ant-tag {
justify-self: start;
}
.photo-puzzle-app__level-group--mobile {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
margin-top: -2px;
}
.photo-puzzle-app__level-group--mobile .photo-puzzle-app__chip {
min-height: 28px;
padding-inline: 0;
font-size: 10px;
}
}

View File

@@ -0,0 +1,315 @@
export type PhotoPuzzleCategory = 'cheerleader' | 'idol' | 'bikini' | 'model' | 'glamour';
export type PhotoPuzzleImage = {
id: string;
title: string;
category: PhotoPuzzleCategory;
imageUrl: string;
badge: string;
};
export const PHOTO_PUZZLE_IMAGES: PhotoPuzzleImage[] = [
{
id: 'cheer-01',
title: '한국인 치어리더 컷 01',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/cheer-01.jpg',
badge: '치어리더',
},
{
id: 'cheer-02',
title: '한국인 치어리더 컷 02',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/cheer-02.jpg',
badge: '치어리더',
},
{
id: 'cheer-03',
title: '한국인 치어리더 컷 03',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/cheer-03.jpg',
badge: '치어리더',
},
{
id: 'kim-na-yeon-cheerleader-2024',
title: '김나연 치어리더 컷 2024',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/kim-na-yeon-cheerleader-2024.jpg',
badge: '치어리더',
},
{
id: 'lee-ju-eun-cheerleader-2024',
title: '이주은 치어리더 컷 2024',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-ju-eun-cheerleader-2024.png',
badge: '치어리더',
},
{
id: 'lee-da-hye-cheerleader',
title: '이다혜 치어리더 컷',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-cheerleader.jpg',
badge: '치어리더',
},
{
id: 'lee-ju-eun-cheerleader-2024-full',
title: '이주은 KIA 치어리더 컷 2024 확장',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-ju-eun-cheerleader-2024-full.jpg',
badge: 'KIA 치어',
},
{
id: 'kim-na-yeon-cheerleader-2024-cropped',
title: '김나연 KIA 치어리더 컷 2024 클로즈업',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/kim-na-yeon-cheerleader-2024-cropped.png',
badge: 'KIA 치어',
},
{
id: 'kim-na-yeon-cheerleader-portrait',
title: '김나연 KIA 치어리더 포트레이트',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/kim-na-yeon-cheerleader.jpg',
badge: 'KIA 치어',
},
{
id: 'lee-da-hye-2025-full',
title: '이다혜 KIA 치어리더 이벤트 컷 2025 01',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-2025-full.jpg',
badge: 'KIA 치어',
},
{
id: 'lee-da-hye-2025-cropped',
title: '이다혜 KIA 치어리더 이벤트 컷 2025 02',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-2025-cropped.png',
badge: 'KIA 치어',
},
{
id: 'byun-ha-yul-cheerleader-2023',
title: '변하율 KIA 치어리더 컷 2023',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/byun-ha-yul-cheerleader.jpg',
badge: 'KIA 치어',
},
{
id: 'byun-ha-yul-glass-house-photo',
title: '변하율 KIA 치어리더 팬 이벤트 컷 2025 01',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/byun-ha-yul-glass-house-photo.jpg',
badge: 'KIA 치어',
},
{
id: 'byun-ha-yul-glass-house-hair',
title: '변하율 KIA 치어리더 팬 이벤트 컷 2025 02',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/byun-ha-yul-glass-house-hair.jpg',
badge: 'KIA 치어',
},
{
id: 'byun-ha-yul-hayul',
title: '변하율 KIA 치어리더 포트레이트',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/byun-ha-yul-hayul.jpg',
badge: 'KIA 치어',
},
{
id: 'kia-lineup-frame-01',
title: '이다혜·변하율 KIA 응원 라인업 01',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-01.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-02',
title: '이다혜·변하율 KIA 응원 라인업 02',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-02.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-03',
title: '이다혜·변하율 KIA 응원 라인업 03',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-03.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-04',
title: '이다혜·변하율 KIA 응원 라인업 04',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-04.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-05',
title: '이다혜·변하율 KIA 응원 라인업 05',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-05.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-06',
title: '이다혜·변하율 KIA 응원 라인업 06',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-06.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-07',
title: '이다혜·변하율 KIA 응원 라인업 07',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-07.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-08',
title: '이다혜·변하율 KIA 응원 라인업 08',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-08.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-09',
title: '이다혜·변하율 KIA 응원 라인업 09',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-09.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-10',
title: '이다혜·변하율 KIA 응원 라인업 10',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-10.jpg',
badge: 'KIA 라인업',
},
{
id: 'kia-lineup-frame-11',
title: '이다혜·변하율 KIA 응원 라인업 11',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-11.jpg',
badge: 'KIA 라인업',
},
{
id: 'snsd-kbs-2011',
title: '한국 아이돌 소녀시대 KBS 가요대축제',
category: 'idol',
imageUrl: '/assets/play/photo-puzzle/photos/snsd-kbs-2011.jpg',
badge: '아이돌',
},
{
id: 'snsd-2012-group',
title: '한국 아이돌 소녀시대 2012 단체',
category: 'idol',
imageUrl: '/assets/play/photo-puzzle/photos/snsd-2012-group.jpg',
badge: '아이돌',
},
{
id: 'snsd-fansigning-2017',
title: '한국 아이돌 소녀시대 팬사인회',
category: 'idol',
imageUrl: '/assets/play/photo-puzzle/photos/snsd-fansigning-2017-02.jpg',
badge: '아이돌',
},
{
id: 'korea-oceanworld-bikini-2013',
title: '오션월드 비키니 코리아 2013',
category: 'bikini',
imageUrl: '/assets/play/photo-puzzle/photos/korea-oceanworld-bikini-2013-02.jpg',
badge: '비키니',
},
{
id: 'korea-wbff-bikini-2017',
title: 'WBFF 코리아 비키니 2017',
category: 'bikini',
imageUrl: '/assets/play/photo-puzzle/photos/korea-wbff-bikini-2017-13.jpg',
badge: '비키니',
},
{
id: 'lee-soo-jeong-model-2013',
title: '이수정 모델 컷',
category: 'model',
imageUrl: '/assets/play/photo-puzzle/photos/lee-soo-jeong-model-2013.jpg',
badge: '모델',
},
{
id: 'korea-racing-model-awards-2016',
title: '레이싱모델 어워즈 2016',
category: 'model',
imageUrl: '/assets/play/photo-puzzle/photos/korea-racing-model-awards-2016-33.jpg',
badge: '모델',
},
{
id: 'joo-daha-2012',
title: '주다하 레이싱모델 2012',
category: 'model',
imageUrl: '/assets/play/photo-puzzle/photos/joo-daha-2012.jpg',
badge: '모델',
},
{
id: 'ju-daha-seoul-motor-show-2013',
title: '주다하 서울모터쇼 2013',
category: 'model',
imageUrl: '/assets/play/photo-puzzle/photos/ju-daha-seoul-motor-show-2013.jpg',
badge: '모델',
},
{
id: 'joo-da-ha-acrofan',
title: '주다하 화보 컷 01',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/joo-da-ha-acrofan.jpg',
badge: '글래머',
},
{
id: 'ju-daha-hunkinelvis-01',
title: '주다하 화보 컷 02',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/ju-daha-hunkinelvis-01.jpg',
badge: '글래머',
},
{
id: 'ju-daha-hunkinelvis-02',
title: '주다하 화보 컷 03',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/ju-daha-hunkinelvis-02.jpg',
badge: '글래머',
},
{
id: 'ju-daha-hunkinelvis-04',
title: '주다하 화보 컷 04',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/ju-daha-hunkinelvis-04.jpg',
badge: '글래머',
},
{
id: 'song-danbi-glamour',
title: '송단비 글래머 컷',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/song-danbi-glamour.jpg',
badge: '글래머',
},
{
id: 'song-subin-glamour',
title: '송수빈 글래머 컷',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/song-subin-glamour.jpg',
badge: '글래머',
},
{
id: 'seoul-motorcycle-show-glamour-2018',
title: '서울모터사이클쇼 글래머 컷 2018',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/seoul-motorcycle-show-glamour-2018.jpg',
badge: '글래머',
},
];
export const PHOTO_PUZZLE_CATEGORY_LABELS: Record<'all' | PhotoPuzzleCategory, string> = {
all: '전체',
cheerleader: '치어리더',
idol: '아이돌',
bikini: '비키니',
model: '모델',
glamour: '글래머',
};

View File

@@ -0,0 +1,413 @@
export type PhotoPuzzleAssetCategory = 'cheerleader' | 'idol' | 'bikini' | 'model' | 'glamour';
export type PhotoPuzzleAsset = {
id: string;
title: string;
category: PhotoPuzzleAssetCategory;
imageUrl: string;
sourcePageUrl: string;
licenseLabel: string;
licenseUrl: string;
credit: string;
};
export const PHOTO_PUZZLE_ASSETS: PhotoPuzzleAsset[] = [
{
id: 'kim-na-yeon-cheerleader-2024',
title: 'Kim Na-yeon cheerleader 2024',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/kim-na-yeon-cheerleader-2024.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:20240721_%EA%B9%80%EB%82%98%EC%97%B0_%28KIM_NAYEON%29.png',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '펌킨이야 / Wikimedia Commons',
},
{
id: 'lee-ju-eun-cheerleader-2024',
title: 'Lee Ju-eun cheerleader 2024',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-ju-eun-cheerleader-2024.png',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:240909_%EC%9D%B4%EC%A3%BC%EC%9D%80_%E6%9D%8E%E7%8F%A0%E7%8F%A2.png',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '화너니 / Wikimedia Commons',
},
{
id: 'lee-da-hye-cheerleader',
title: 'Lee Da-hye cheerleader',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-cheerleader.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:%EC%9D%B4%EB%8B%A4%ED%98%9C_%EC%B9%98%EC%96%B4%EB%A6%AC%EB%8D%94.jpg',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '알콩달콩 / Wikimedia Commons',
},
{
id: 'lee-ju-eun-cheerleader-2024-full',
title: '이주은 KIA 치어리더 컷 2024 확장',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-ju-eun-cheerleader-2024-full.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:240909_%EC%9D%B4%EC%A3%BC%EC%9D%80_%E6%9D%8E%E7%8F%A0%E7%8F%A2.png',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '화너니 / Wikimedia Commons',
},
{
id: 'kim-na-yeon-cheerleader-2024-cropped',
title: '김나연 KIA 치어리더 컷 2024 클로즈업',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/kim-na-yeon-cheerleader-2024-cropped.png',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:20240721_%EA%B9%80%EB%82%98%EC%97%B0_(KIM_NAYEON)_(cropped).png',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '펌킨이야 / Wikimedia Commons',
},
{
id: 'kim-na-yeon-cheerleader-portrait',
title: '김나연 KIA 치어리더 포트레이트',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/kim-na-yeon-cheerleader.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:%EA%B9%80%EB%82%98%EC%97%B0_%EC%B9%98%EC%96%B4%EB%A6%AC%EB%8D%94.jpg',
licenseLabel: 'CC0',
licenseUrl: 'http://creativecommons.org/publicdomain/zero/1.0/deed.en',
credit: 'Baur2321 / Wikimedia Commons',
},
{
id: 'lee-da-hye-2025-full',
title: '이다혜 KIA 치어리더 이벤트 컷 2025 01',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-2025-full.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:2025.01.17_%E6%9D%8E%E5%A4%9A%E6%85%A7_%EC%9D%B4%EB%8B%A4%ED%98%9C.png',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '浪仁 / Wikimedia Commons',
},
{
id: 'lee-da-hye-2025-cropped',
title: '이다혜 KIA 치어리더 이벤트 컷 2025 02',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-2025-cropped.png',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:2025.01.17_%E6%9D%8E%E5%A4%9A%E6%85%A7_%EC%9D%B4%EB%8B%A4%ED%98%9C_(cropped).png',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '浪仁 / Wikimedia Commons',
},
{
id: 'byun-ha-yul-cheerleader-2023',
title: '변하율 KIA 치어리더 컷 2023',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/byun-ha-yul-cheerleader.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:%EB%B3%80%ED%95%98%EC%9C%A8_%EC%B9%98%EC%96%B4%EB%A6%AC%EB%8D%94.jpg',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'byun-ha-yul-glass-house-photo',
title: '변하율 KIA 치어리더 팬 이벤트 컷 2025 01',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/byun-ha-yul-glass-house-photo.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Byun_Ha-yul_and_her_male_fan_holding_their_photo_at_the_glass_house_20251005.jpg',
licenseLabel: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
credit: 'Solomon203 / Wikimedia Commons',
},
{
id: 'byun-ha-yul-glass-house-hair',
title: '변하율 KIA 치어리더 팬 이벤트 컷 2025 02',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/byun-ha-yul-glass-house-hair.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Byun_Ha-yul_holding_her_hair_at_the_glass_house,_iPhone_outside_20251005.jpg',
licenseLabel: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
credit: 'Solomon203 / Wikimedia Commons',
},
{
id: 'byun-ha-yul-hayul',
title: '변하율 KIA 치어리더 포트레이트',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/byun-ha-yul-hayul.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Hayul.jpg',
licenseLabel: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
credit: 'Reams77 / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-01',
title: '이다혜·변하율 KIA 응원 라인업 01',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-01.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-02',
title: '이다혜·변하율 KIA 응원 라인업 02',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-02.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-03',
title: '이다혜·변하율 KIA 응원 라인업 03',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-03.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-04',
title: '이다혜·변하율 KIA 응원 라인업 04',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-04.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-05',
title: '이다혜·변하율 KIA 응원 라인업 05',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-05.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-06',
title: '이다혜·변하율 KIA 응원 라인업 06',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-06.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-07',
title: '이다혜·변하율 KIA 응원 라인업 07',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-07.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-08',
title: '이다혜·변하율 KIA 응원 라인업 08',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-08.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-09',
title: '이다혜·변하율 KIA 응원 라인업 09',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-09.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-10',
title: '이다혜·변하율 KIA 응원 라인업 10',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-10.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'kia-lineup-frame-11',
title: '이다혜·변하율 KIA 응원 라인업 11',
category: 'cheerleader',
imageUrl: '/assets/play/photo-puzzle/photos/lee-da-hye-byun-ha-yul-kia-lineup-11.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:22.10.04_%EA%B8%B0%EC%95%84vs%EC%97%98%EC%A7%80_%EC%8A%B9%EB%A6%AC%EC%9D%98_%EB%9D%BC%EC%9D%B8%EC%97%85!!_%EC%9D%B4%EB%8B%A4%ED%98%9C_%EB%B3%80%ED%95%98%EC%9C%A8_%EB%85%B8%EB%A7%88%EC%8A%A4%ED%81%AC_%EC%9D%91%EC%9B%90%EC%A7%81%EC%BA%A0.webm',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: '가냥이♡ / Wikimedia Commons',
},
{
id: 'snsd-kbs-2011',
title: 'Girls Generation KBS 2011',
category: 'idol',
imageUrl: '/assets/play/photo-puzzle/photos/snsd-kbs-2011.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:111230_KBS_%EA%B0%80%EC%9A%94_%EB%8C%80%EC%B6%95%EC%A0%9C_%EC%86%8C%EB%85%80%EC%8B%9C%EB%8C%80.jpg',
licenseLabel: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
credit: 'Daum / Wikimedia Commons',
},
{
id: 'snsd-2012-group',
title: 'Girls Generation 2012',
category: 'idol',
imageUrl: '/assets/play/photo-puzzle/photos/snsd-2012-group.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Girls%27_Generation_2012.jpg',
licenseLabel: 'CC BY 2.0',
licenseUrl: 'https://creativecommons.org/licenses/by/2.0/',
credit: 'KOREA.net / Wikimedia Commons',
},
{
id: 'snsd-fansigning-2017-02',
title: 'Girls Generation Fansigning 2017 02',
category: 'idol',
imageUrl: '/assets/play/photo-puzzle/photos/snsd-fansigning-2017-02.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Girls%27_Generation_at_fansigning_event_in_August_2017_02.jpg',
licenseLabel: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
credit: 'KoreabooMania / Wikimedia Commons',
},
{
id: 'korea-oceanworld-bikini-2013',
title: 'Ocean World Bikini Korea 2013',
category: 'bikini',
imageUrl: '/assets/play/photo-puzzle/photos/korea-oceanworld-bikini-2013-02.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Ocean_World_Bikini_Korea_2013.jpg',
licenseLabel: 'CC BY 2.0',
licenseUrl: 'https://creativecommons.org/licenses/by/2.0/',
credit: 'Travel oriented photographer / Wikimedia Commons',
},
{
id: 'korea-wbff-bikini-2017',
title: 'WBFF Korea Bikini 2017',
category: 'bikini',
imageUrl: '/assets/play/photo-puzzle/photos/korea-wbff-bikini-2017-13.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:WBFF_KOREA_2017_13.jpg',
licenseLabel: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
credit: 'Lklp5016 / Wikimedia Commons',
},
{
id: 'lee-soo-jeong-model-2013',
title: 'Lee Soo-jeong model 2013',
category: 'model',
imageUrl: '/assets/play/photo-puzzle/photos/lee-soo-jeong-model-2013.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Lee_Soo-jung_at_World_IT_Show_2013.jpg',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: 'KOREA.NET / Wikimedia Commons',
},
{
id: 'korea-racing-model-awards-2016',
title: 'Korea Racing Model Awards 2016',
category: 'model',
imageUrl: '/assets/play/photo-puzzle/photos/korea-racing-model-awards-2016-33.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:2016_%EB%A0%88%EC%9D%B4%EC%8B%B1%EB%AA%A8%EB%8D%B8_%EC%96%B4%EC%9B%8C%EC%A6%88_33.jpg',
licenseLabel: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
credit: 'Asta0902 / Wikimedia Commons',
},
{
id: 'joo-daha-2012',
title: 'Joo Daha in 2012',
category: 'model',
imageUrl: '/assets/play/photo-puzzle/photos/joo-daha-2012.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Joo_Daha_in_2012.jpg',
licenseLabel: 'CC BY 2.0',
licenseUrl: 'https://creativecommons.org/licenses/by/2.0/',
credit: 'KIYOUNG KIM / Wikimedia Commons',
},
{
id: 'ju-daha-seoul-motor-show-2013',
title: 'Ju Daha at the 2013 Seoul Motor Show',
category: 'model',
imageUrl: '/assets/play/photo-puzzle/photos/ju-daha-seoul-motor-show-2013.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Ju_Daha_at_the_2013_Seoul_Motor_Show.JPG',
licenseLabel: 'CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
credit: 'Wikimedia Commons contributor',
},
{
id: 'joo-da-ha-acrofan',
title: 'Joo Da-Ha from acrofan',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/joo-da-ha-acrofan.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:Joo_Da-Ha_from_acrofan.jpg',
licenseLabel: 'CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
credit: 'ACROFAN / Wikimedia Commons',
},
{
id: 'ju-daha-hunkinelvis-01',
title: 'Ju Daha photo by HunkinElvis',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/ju-daha-hunkinelvis-01.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:JuDaha-photo_by_HunkinElvis.jpg',
licenseLabel: 'CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
credit: 'HunkinElvis / Wikimedia Commons',
},
{
id: 'ju-daha-hunkinelvis-02',
title: 'Ju Daha photo by HunkinElvis 2',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/ju-daha-hunkinelvis-02.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:JuDaha-photo_by_HunkinElvis2.JPG',
licenseLabel: 'CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
credit: 'HunkinElvis / Wikimedia Commons',
},
{
id: 'ju-daha-hunkinelvis-04',
title: 'Ju Daha photo by HunkinElvis 4',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/ju-daha-hunkinelvis-04.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:JuDaha-photo_by_HunkinElvis4.jpg',
licenseLabel: 'CC BY-SA 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
credit: 'HunkinElvis / Wikimedia Commons',
},
{
id: 'song-danbi-glamour',
title: 'Song Danbi glamour',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/song-danbi-glamour.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:%EC%86%A1%EB%8B%A8%EB%B9%84_%EB%AA%A8%EB%8D%B8.jpg',
licenseLabel: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
credit: 'ENTERLIVE / Wikimedia Commons',
},
{
id: 'song-subin-glamour',
title: 'Song Subin glamour',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/song-subin-glamour.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:%EC%86%A1%EC%88%98%EB%B9%88_%EB%AA%A8%EB%8D%B8.jpg',
licenseLabel: 'CC BY 3.0',
licenseUrl: 'https://creativecommons.org/licenses/by/3.0/',
credit: 'Jeff Video / Wikimedia Commons',
},
{
id: 'seoul-motorcycle-show-glamour-2018',
title: 'Seoul Motorcycle Show glamour 2018',
category: 'glamour',
imageUrl: '/assets/play/photo-puzzle/photos/seoul-motorcycle-show-glamour-2018.jpg',
sourcePageUrl: 'https://commons.wikimedia.org/wiki/File:2018_%EC%84%9C%EC%9A%B8%EB%AA%A8%ED%84%B0%EC%82%AC%EC%9D%B4%ED%81%B4%EC%87%BC_%EB%A0%88%EC%9D%B4%EC%8B%B1%EB%AA%A8%EB%8D%B8_%2849%29.jpg',
licenseLabel: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
credit: 'sj / Wikimedia Commons',
},
];
export const PHOTO_PUZZLE_LIBRARY_BADGES: Record<PhotoPuzzleAssetCategory, string> = {
cheerleader: 'Cheerleader',
idol: 'Idol',
bikini: 'Bikini',
model: 'Model',
glamour: 'Glamour',
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,359 @@
const PHOTOPRISM_API_BASE = '/api/play-apps/photoprism';
export type PhotoPrismStoredSession = {
baseUrl: string;
username: string;
password: string;
rememberPassword: boolean;
token: string;
};
export type PhotoPrismAlbum = {
UID?: string;
Title?: string;
Name?: string;
Caption?: string;
Thumb?: string;
Favorite?: boolean;
Type?: string;
};
export type PhotoPrismFile = {
UID?: string;
Name?: string;
Hash?: string;
Primary?: boolean;
Mime?: string;
Width?: number;
Height?: number;
};
export type PhotoPrismPhoto = {
UID: string;
Title?: string;
Description?: string;
TakenAt?: string;
PlaceLabel?: string;
Country?: string;
City?: string;
CameraModel?: string;
LensModel?: string;
LensMake?: string;
FocalLength?: number;
FNumber?: number;
Exposure?: string;
Iso?: number;
OriginalName?: string;
FileName?: string;
Type?: string;
Hash?: string;
Width?: number;
Height?: number;
Resolution?: number;
Filesize?: number;
Quality?: number;
Portrait?: boolean;
Panorama?: boolean;
LivePhoto?: boolean;
Favorite?: boolean;
Albums?: PhotoPrismAlbum[];
Files?: PhotoPrismFile[];
};
export type PhotoPrismSearchMeta = {
count: number;
limit: number;
offset: number;
hasMore?: boolean;
nextOffset?: number;
previewToken: string;
downloadToken: string;
};
type PhotoPrismJsonResponse<T> = {
ok: boolean;
items: T[];
meta: PhotoPrismSearchMeta;
};
type PhotoPrismItemResponse<T> = {
ok: boolean;
item: T;
};
type PhotoPrismMessageResponse = {
ok: boolean;
item?: PhotoPrismPhoto;
message?: string;
};
async function requestPhotoPrism<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${PHOTOPRISM_API_BASE}${path}`, {
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : null),
...(init?.headers ?? {}),
},
});
let payload: unknown = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
const message =
payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string'
? payload.message
: 'PhotoPrism 요청에 실패했습니다.';
throw new Error(message);
}
return payload as T;
}
export async function createPhotoPrismSession(input: {
baseUrl: string;
username: string;
password: string;
}) {
return requestPhotoPrism<{
ok: boolean;
token: string;
session: Record<string, unknown>;
}>('/session', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function fetchPhotoPrismAlbums(input: {
baseUrl: string;
token: string;
q?: string;
count?: number;
}) {
const params = new URLSearchParams({
baseUrl: input.baseUrl,
token: input.token,
count: String(input.count ?? 160),
});
params.set('q', input.q?.trim() || 'type:album');
return requestPhotoPrism<PhotoPrismJsonResponse<PhotoPrismAlbum>>(`/albums?${params.toString()}`);
}
export async function fetchPhotoPrismPhotos(input: {
baseUrl: string;
token: string;
albumUid?: string;
order?: string;
q?: string;
count?: number;
offset?: number;
}) {
const params = new URLSearchParams({
baseUrl: input.baseUrl,
token: input.token,
count: String(input.count ?? 180),
});
if (typeof input.offset === 'number' && Number.isFinite(input.offset) && input.offset > 0) {
params.set('offset', String(Math.max(0, Math.round(input.offset))));
}
if (input.albumUid?.trim()) {
params.set('albumUid', input.albumUid.trim());
}
if (input.order?.trim()) {
params.set('order', input.order.trim());
}
if (input.q?.trim()) {
params.set('q', input.q.trim());
}
return requestPhotoPrism<PhotoPrismJsonResponse<PhotoPrismPhoto>>(`/photos?${params.toString()}`);
}
export async function fetchPhotoPrismPhotoDetail(input: {
baseUrl: string;
token: string;
uid: string;
}) {
const params = new URLSearchParams({
baseUrl: input.baseUrl,
token: input.token,
uid: input.uid,
});
return requestPhotoPrism<PhotoPrismItemResponse<PhotoPrismPhoto>>(`/photo?${params.toString()}`);
}
export async function updatePhotoPrismPhotoFavorite(input: {
baseUrl: string;
token: string;
uid: string;
favorite: boolean;
}) {
return requestPhotoPrism<PhotoPrismMessageResponse>('/photo/favorite', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function updatePhotoPrismFavorite(input: {
baseUrl: string;
token: string;
uid: string;
favorite: boolean;
}) {
return requestPhotoPrism<PhotoPrismItemResponse<PhotoPrismPhoto>>('/photo/favorite', {
method: 'PUT',
body: JSON.stringify(input),
});
}
export async function updatePhotoPrismPhotoAlbumMembership(input: {
baseUrl: string;
token: string;
uid: string;
albumUid: string;
action: 'add' | 'remove';
}) {
return requestPhotoPrism<PhotoPrismItemResponse<PhotoPrismPhoto>>('/photo/albums', {
method: input.action === 'add' ? 'POST' : 'DELETE',
body: JSON.stringify({
baseUrl: input.baseUrl,
token: input.token,
uid: input.uid,
albumUid: input.albumUid,
}),
});
}
export function buildPhotoPrismThumbUrl(input: {
baseUrl: string;
hash: string;
previewToken: string;
token?: string;
size?: string;
}) {
const params = new URLSearchParams({
baseUrl: input.baseUrl,
hash: input.hash,
previewToken: input.previewToken,
size: input.size ?? 'fit_720',
});
if (input.token?.trim()) {
params.set('token', input.token.trim());
}
return `${PHOTOPRISM_API_BASE}/thumb?${params.toString()}`;
}
export function buildPhotoPrismViewerUrl(input: {
baseUrl: string;
token: string;
uid?: string;
hash?: string;
previewToken?: string;
downloadToken?: string;
size?: string;
}) {
const params = new URLSearchParams({
baseUrl: input.baseUrl,
token: input.token,
size: input.size ?? 'fit_2560',
});
if (input.uid?.trim()) {
params.set('uid', input.uid.trim());
}
if (input.hash?.trim()) {
params.set('hash', input.hash.trim());
}
if (input.previewToken?.trim()) {
params.set('previewToken', input.previewToken.trim());
}
if (input.downloadToken?.trim()) {
params.set('downloadToken', input.downloadToken.trim());
}
return `${PHOTOPRISM_API_BASE}/viewer?${params.toString()}`;
}
export function buildPhotoPrismDownloadUrl(input: {
baseUrl: string;
token: string;
downloadToken: string;
uid?: string;
hash?: string;
}) {
const params = new URLSearchParams({
baseUrl: input.baseUrl,
token: input.token,
downloadToken: input.downloadToken,
});
if (input.uid) {
params.set('uid', input.uid);
}
if (input.hash) {
params.set('hash', input.hash);
}
return `${PHOTOPRISM_API_BASE}/download?${params.toString()}`;
}
export function getPrimaryPhotoFile(photo: PhotoPrismPhoto) {
return photo.Files?.find((file) => file.Primary) ?? photo.Files?.[0] ?? null;
}
export function isPhotoPrismVideo(photo: PhotoPrismPhoto) {
const primaryFile = getPrimaryPhotoFile(photo);
const mime = primaryFile?.Mime?.trim().toLowerCase() ?? '';
const type = photo.Type?.trim().toLowerCase() ?? '';
const fileName = (primaryFile?.Name || photo.OriginalName || photo.FileName || '').trim().toLowerCase();
if (mime.startsWith('video/')) {
return true;
}
if (type.includes('video')) {
return true;
}
return /\.(mp4|mov|m4v|webm|avi|mkv|3gp|mts|m2ts)$/i.test(fileName);
}
export function getPhotoDisplayTitle(photo: PhotoPrismPhoto) {
return photo.Title?.trim() || photo.OriginalName?.trim() || photo.FileName?.trim() || photo.UID;
}
export function getPhotoDisplayDate(photo: PhotoPrismPhoto) {
if (!photo.TakenAt) {
return '';
}
try {
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(photo.TakenAt));
} catch {
return photo.TakenAt;
}
}

View File

@@ -1,97 +1,613 @@
.test-play-app { .test-play-app {
--test-play-text: #2f3a47;
--test-play-text-strong: #202b37;
--test-play-text-muted: #697586;
--test-play-border: #e2e5ea;
--test-play-border-strong: #d2d8e0;
--test-play-surface: rgba(255, 255, 255, 0.92);
--test-play-surface-strong: rgba(252, 252, 253, 0.97);
--test-play-shadow: rgba(77, 88, 102, 0.1);
--test-play-accent: #1677ff;
--test-play-accent-strong: #0958d9;
--test-play-accent-soft: #e8f3ff;
--test-play-danger: #c97e89;
--test-play-danger-strong: #b46572;
--test-play-danger-soft: #fff0f2;
--test-play-warning: #c7ab67;
--test-play-warning-strong: #aa8740;
--test-play-warning-soft: #f8f1de;
--test-play-active-soft: #eef4ff;
--test-play-header-start: #fbfcfd;
--test-play-header-end: #f1f4f8;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px;
height: 100%; height: 100%;
min-height: 100%; min-height: 0;
overflow: auto; max-height: 100%;
overscroll-behavior: contain; overflow: hidden;
-webkit-overflow-scrolling: touch; padding: 8px;
padding: 32px;
background: background:
radial-gradient(circle at top left, rgba(255, 211, 105, 0.24), transparent 28%), radial-gradient(circle at top left, rgba(255, 211, 105, 0.18), transparent 28%),
linear-gradient(160deg, #f4efe2 0%, #fcfaf4 48%, #eef3f8 100%); linear-gradient(160deg, #f4efe2 0%, #fcfaf4 48%, #eef3f8 100%);
color: var(--test-play-text);
} }
.test-play-app__hero { .test-play-app__filters,
display: grid; .test-play-app__grid-panel {
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr); min-height: 0;
align-items: start; border: 1px solid var(--test-play-border);
gap: 24px; border-radius: 14px;
margin-bottom: 24px; background: var(--test-play-surface);
box-shadow: 0 12px 28px var(--test-play-shadow);
} }
.test-play-app__hero-copy, .test-play-app__filters {
.test-play-app__spotlight,
.test-play-app__feature-card {
border-radius: 28px;
border: 1px solid rgba(34, 49, 63, 0.08);
box-shadow: 0 22px 60px rgba(63, 79, 92, 0.08);
}
.test-play-app__hero-copy {
padding: 32px;
background: rgba(255, 252, 244, 0.82);
backdrop-filter: blur(14px);
}
.test-play-app__hero-copy .ant-typography h2 {
margin-top: 14px;
margin-bottom: 14px;
font-size: clamp(2rem, 3vw, 3.2rem);
line-height: 1.04;
}
.test-play-app__hero-copy .ant-typography {
max-width: 640px;
}
.test-play-app__spotlight {
background: linear-gradient(180deg, #1e2b31 0%, #263b45 100%);
}
.test-play-app__spotlight .ant-card-body {
display: flex; display: flex;
flex: 0 0 auto;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
padding: 12px;
}
.test-play-app__heading strong {
display: block;
font-size: 15px;
font-weight: 700;
color: var(--test-play-text-strong);
}
.test-play-app__filters-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.test-play-app__search-input {
flex: 1;
min-width: 0;
}
.test-play-app__search-input .ant-input {
height: 38px;
}
.test-play-app :where(.ant-input, .ant-select-selector) {
border-color: var(--test-play-border-strong) !important;
background: rgba(255, 255, 255, 0.98) !important;
color: var(--test-play-text) !important;
box-shadow: none !important;
}
.test-play-app :where(.ant-input:hover, .ant-select:hover .ant-select-selector) {
border-color: #b7c0cb !important;
}
.test-play-app :where(.ant-input:focus, .ant-input-focused, .ant-select-focused .ant-select-selector) {
border-color: var(--test-play-accent) !important;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.14) !important;
}
.test-play-app__icon-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.test-play-app__icon-button.ant-btn {
width: 38px;
min-width: 38px;
height: 38px;
padding: 0;
border-radius: 12px;
border-color: #d6dbe3;
background: #ffffff;
color: #3f4b59;
box-shadow: 0 6px 14px rgba(77, 88, 102, 0.08);
}
.test-play-app__icon-button.ant-btn:hover,
.test-play-app__icon-button.ant-btn:focus-visible {
border-color: #8cb8ff;
background: #ffffff;
color: #1f2937;
box-shadow:
0 0 0 2px rgba(22, 119, 255, 0.14),
0 8px 18px rgba(77, 88, 102, 0.1);
}
.test-play-app__icon-button.ant-btn.ant-btn-primary,
.test-play-app__icon-button--primary.ant-btn {
border-color: var(--test-play-accent);
background: var(--test-play-accent);
color: #ffffff;
box-shadow: 0 8px 18px rgba(22, 119, 255, 0.22);
}
.test-play-app__icon-button.ant-btn.ant-btn-primary:hover,
.test-play-app__icon-button.ant-btn.ant-btn-primary:focus-visible,
.test-play-app__icon-button--primary.ant-btn:hover,
.test-play-app__icon-button--primary.ant-btn:focus-visible {
border-color: var(--test-play-accent-strong);
background: var(--test-play-accent-strong);
color: #ffffff;
box-shadow:
0 0 0 2px rgba(22, 119, 255, 0.18),
0 10px 22px rgba(22, 119, 255, 0.24);
}
.test-play-app__icon-button--danger.ant-btn {
border-color: #e3b7c3;
background: linear-gradient(180deg, #fff9fb 0%, #fbe8ee 100%);
color: var(--test-play-danger-strong);
box-shadow:
0 8px 18px rgba(201, 126, 137, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.54);
}
.test-play-app__icon-button--danger.ant-btn:hover,
.test-play-app__icon-button--danger.ant-btn:focus-visible {
border-color: var(--test-play-danger);
background: linear-gradient(180deg, #fff7fa 0%, #f7dfe8 100%);
color: var(--test-play-danger-strong);
box-shadow:
0 0 0 2px rgba(201, 134, 156, 0.18),
0 10px 20px rgba(201, 126, 137, 0.18);
}
.test-play-app__icon-button.ant-btn:disabled,
.test-play-app__icon-button.ant-btn.ant-btn-disabled,
.test-play-app__icon-button.ant-btn[disabled] {
border-color: #e2e6ec !important;
background: #f6f7f9 !important;
color: #9aa3af !important;
box-shadow: none !important;
opacity: 1;
}
.test-play-app__filters-detail {
display: grid;
min-height: 0; min-height: 0;
color: #f5f6f8;
} }
.test-play-app__spotlight .ant-typography, .test-play-app__filters-toggle.ant-btn {
.test-play-app__spotlight .ant-typography-copy, flex: 0 0 auto;
.test-play-app__spotlight .ant-typography-secondary {
color: inherit;
} }
.test-play-app__feature-card { .test-play-app__filters-extra-shell {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 0.28s ease;
}
.test-play-app__filters-detail--expanded .test-play-app__filters-extra-shell {
grid-template-rows: 1fr;
}
.test-play-app__filters-extra {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
min-height: 0;
overflow: hidden;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 0.22s ease,
transform 0.28s ease,
padding-bottom 0.28s ease;
}
.test-play-app__filters-detail--expanded .test-play-app__filters-extra {
padding-top: 10px;
border-top: 1px solid #eceff3;
padding-bottom: 4px;
opacity: 1;
transform: translateY(0);
}
.test-play-app__filter-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.test-play-app__filter-field > span {
font-size: 12px;
color: var(--test-play-text-muted);
}
.test-play-app__grid-panel {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.test-play-app__grid-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e8ebf0;
background: linear-gradient(180deg, #fcfcfd 0%, #f5f7fa 100%);
}
.test-play-app__grid-meta-inline {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
color: var(--test-play-text-muted);
font-size: 12px;
white-space: nowrap;
}
.test-play-app__grid-meta-inline-range,
.test-play-app__grid-meta-inline-page {
white-space: nowrap;
}
.test-play-app__grid-meta-inline-divider {
color: #aab3be;
}
.test-play-app__grid-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin-left: auto;
}
.test-play-app__grid-surface {
flex: 1;
min-height: 0;
overflow: hidden;
}
.test-play-app__grid-surface .ant-spin-nested-loading,
.test-play-app__grid-surface .ant-spin-container {
height: 100%; height: 100%;
background: rgba(255, 255, 255, 0.84);
} }
.test-play-app__feature-label { .test-play-app__grid-surface .ag-root-wrapper {
display: inline-block; height: 100%;
margin-bottom: 8px; border: 0;
font-size: 0.78rem; border-radius: 0;
letter-spacing: 0.08em; }
text-transform: uppercase;
color: #7d5b1f; .test-play-app__grid-surface .ag-header {
border-bottom: 1px solid #dfe5ec;
background: linear-gradient(180deg, var(--test-play-header-start) 0%, var(--test-play-header-end) 100%);
}
.test-play-app__grid-surface .ag-header-cell,
.test-play-app__grid-surface .ag-header-group-cell {
color: #4a5563;
font-weight: 700;
background: transparent;
box-shadow: inset -1px 0 0 rgba(202, 208, 217, 0.28);
}
.test-play-app__grid-surface .ag-row {
transition: background-color 0.18s ease;
}
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--odd {
--test-play-row-bg: #ffffff;
}
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--even {
--test-play-row-bg: #fafbfc;
}
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--dirty {
--test-play-row-bg: var(--test-play-warning-soft);
}
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--active {
--test-play-row-bg: var(--test-play-active-soft);
}
.test-play-app__grid-cell.ag-cell {
display: flex;
align-items: center;
background: var(--test-play-row-bg, #fff);
color: var(--test-play-text);
box-shadow: inset 0 -1px 0 rgba(226, 231, 238, 0.9);
}
.test-play-app__grid-cell--edited.ag-cell {
color: #80551d;
font-weight: 700;
background: linear-gradient(180deg, #fbf3dd 0%, #f6ebcf 100%);
box-shadow:
inset 3px 0 0 var(--test-play-warning),
inset 0 -1px 0 rgba(214, 176, 109, 0.42);
}
.test-play-app__grid-cell--active.ag-cell {
box-shadow:
inset 0 0 0 1px rgba(22, 119, 255, 0.2),
inset 0 -1px 0 rgba(226, 231, 238, 0.9);
}
.test-play-app__editor-shell {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
justify-content: flex-end;
pointer-events: none;
overflow: hidden;
}
.test-play-app__editor-shell--open {
pointer-events: auto;
}
.test-play-app__editor-backdrop {
position: absolute;
inset: 0;
border: 0;
background: rgba(44, 63, 79, 0);
transition: background-color 0.28s ease;
cursor: pointer;
}
.test-play-app__editor-shell--open .test-play-app__editor-backdrop {
background: rgba(56, 78, 97, 0.24);
}
.test-play-app__editor-panel {
position: relative;
display: flex;
flex-direction: column;
width: 100vw;
height: 100dvh;
min-height: 100dvh;
background:
radial-gradient(circle at top left, rgba(255, 211, 105, 0.12), transparent 24%),
linear-gradient(160deg, #f8f5ec 0%, #fcfbf7 48%, #f1f4f8 100%);
transform: translate3d(100%, 0, 0);
transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow: -16px 0 44px rgba(86, 110, 125, 0.16);
overflow: hidden;
}
.test-play-app__editor-shell--open .test-play-app__editor-panel {
transform: translate3d(0, 0, 0);
}
.test-play-app__editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: max(18px, env(safe-area-inset-top, 0px)) 24px 18px;
border-bottom: 1px solid rgba(225, 229, 235, 0.92);
background: rgba(252, 251, 248, 0.94);
backdrop-filter: blur(18px);
}
.test-play-app__editor-heading {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.test-play-app__editor-heading strong {
overflow: hidden;
color: var(--test-play-text-strong);
font-size: 24px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.test-play-app__editor-heading span {
color: var(--test-play-text-muted);
font-size: 13px;
}
.test-play-app__editor-actions {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px;
border: 1px solid rgba(220, 225, 232, 0.92);
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 8px 18px rgba(102, 112, 122, 0.08);
}
.test-play-app__editor-actions .ant-tooltip-disabled-compatible-wrapper {
display: inline-flex;
}
.test-play-app__editor-close.ant-btn {
flex: 0 0 auto;
}
.test-play-app__editor-delete.ant-btn,
.test-play-app__editor-close.ant-btn,
.test-play-app__editor-apply.ant-btn {
flex: 0 0 auto;
}
.test-play-app__editor-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px;
}
.test-play-app__editor-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.test-play-app__editor-card,
.test-play-app__editor-form {
border: 1px solid rgba(223, 227, 233, 0.95);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 18px 36px rgba(102, 112, 122, 0.08);
}
.test-play-app__editor-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px;
}
.test-play-app__editor-card-label {
color: var(--test-play-text-muted);
font-size: 12px;
}
.test-play-app__editor-card strong {
color: var(--test-play-text-strong);
font-size: 18px;
}
.test-play-app__editor-form {
padding: 24px;
}
.test-play-app__editor-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.test-play-app__editor-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.test-play-app__editor-field > span {
color: #5e7287;
font-size: 12px;
font-weight: 600;
}
.test-play-app__editor-field .ant-input,
.test-play-app__editor-field .ant-select-selector {
min-height: 42px;
border-radius: 12px !important;
}
.test-play-app__editor-field--edited .ant-input,
.test-play-app__editor-field--edited .ant-select-selector {
border-color: var(--test-play-warning) !important;
background: #faf5e8 !important;
box-shadow: 0 0 0 1px rgba(214, 176, 109, 0.12);
}
.test-play-app__editor-note {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
min-height: 100%;
padding: 18px;
border: 1px dashed #d9dde4;
border-radius: 16px;
background: linear-gradient(180deg, #fffdf8 0%, #f6f2e8 100%);
color: #6a7280;
}
.test-play-app__editor-note strong {
color: #404956;
font-size: 14px;
}
@media (max-width: 1080px) {
.test-play-app__filters-extra {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.test-play-app__editor-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.test-play-app { .test-play-app {
min-height: 100dvh; height: 100%;
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px)); min-height: 0;
padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
} }
.test-play-app__hero { .test-play-app__filters-extra {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.test-play-app__grid-toolbar {
justify-content: flex-end;
}
.test-play-app__grid-actions {
margin-left: 0;
}
.test-play-app__editor-header,
.test-play-app__editor-body,
.test-play-app__editor-form {
padding-left: 16px;
padding-right: 16px;
}
.test-play-app__editor-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.test-play-app__filters-main {
flex-wrap: nowrap;
}
.test-play-app__filters-extra {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.test-play-app__hero-copy { .test-play-app__grid-toolbar {
padding: 24px; gap: 10px;
} }
.test-play-app__spotlight .ant-card-body { .test-play-app__grid-meta-inline {
min-height: 180px; flex: 1 1 auto;
min-width: 0;
}
.test-play-app__grid-actions {
justify-content: flex-end;
}
.test-play-app__editor-heading strong {
font-size: 20px;
white-space: normal;
}
.test-play-app__editor-heading span {
font-size: 12px;
}
.test-play-app__editor-summary {
grid-template-columns: minmax(0, 1fr);
} }
} }

View File

@@ -1,60 +1,678 @@
import { Button, Card, Col, Input, Row, Space, Tag, Typography } from 'antd'; import { CheckOutlined, CloseOutlined, DeleteOutlined, DownOutlined, ReloadOutlined, SaveOutlined, SearchOutlined, UpOutlined } from '@ant-design/icons';
import { Button, Input, Popconfirm, Select, Spin, Tooltip, message } from 'antd';
import type { BodyScrollEndEvent, ColDef, GridReadyEvent, RowDoubleClickedEvent, ValueFormatterParams } from 'ag-grid-community';
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { useEffect, useRef, useState } from 'react';
import {
deleteTestAppMaintenanceRequest,
fetchTestAppMaintenanceRequests,
saveTestAppMaintenanceRequests,
type TestAppMaintenanceRequestFilters,
type TestAppMaintenanceRequestRow,
} from './testPlayAppApi';
import './TestPlayAppView.css'; import './TestPlayAppView.css';
const { Paragraph, Text, Title } = Typography; import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';
const featureCards = [ ModuleRegistry.registerModules([AllCommunityModule]);
{
title: 'Isolated Entry', const PAGE_SIZE = 100;
description: '기존 Layout Editor 상태와 분리된 전용 play 앱 진입점입니다.', const STATUS_OPTIONS = ['전체', '접수', '배정완료', '조치중', '부품대기', '완료'];
}, const PRIORITY_OPTIONS = ['전체', '긴급', '높음', '보통', '낮음'];
{ const LINE_OPTIONS = ['전체', 'PKG', 'MFG', 'UTL', 'QC'];
title: 'Scoped Styling', const EDITABLE_PRIORITY_OPTIONS: TestAppMaintenanceRequestRow['priority'][] = ['긴급', '높음', '보통', '낮음'];
description: '이 화면은 `TestPlayAppView.css`만 사용하도록 분리해 독립 스타일 실험이 가능합니다.', const EDITABLE_STATUS_OPTIONS: TestAppMaintenanceRequestRow['status'][] = ['접수', '배정완료', '조치중', '부품대기', '완료'];
}, const EDITABLE_FIELDS = ['priority', 'status', 'assigneeName'] as const;
{
title: 'Next Extension', type MaintenancePriority = TestAppMaintenanceRequestRow['priority'];
description: '이후 라우트, 상태, API 연결을 현재 앱과 분리된 구조로 계속 확장할 수 있습니다.', type MaintenanceFilterState = TestAppMaintenanceRequestFilters;
}, type MaintenanceRequestRow = TestAppMaintenanceRequestRow;
]; type EditableField = (typeof EDITABLE_FIELDS)[number];
type EditedFieldEntry = Partial<Record<EditableField, true>>;
type EditedFieldMap = Record<number, EditedFieldEntry>;
const INITIAL_FILTERS: MaintenanceFilterState = {
keyword: '',
lineCode: '전체',
priority: '전체',
status: '전체',
requestedFrom: '',
requestedTo: '',
};
function priorityRank(priority: MaintenancePriority) {
if (priority === '긴급') {
return 4;
}
if (priority === '높음') {
return 3;
}
if (priority === '보통') {
return 2;
}
return 1;
}
function formatDateTime(params: ValueFormatterParams<MaintenanceRequestRow>) {
return params.value ? String(params.value).replace('T', ' ').slice(0, 16) : '-';
}
function resolveEditedFieldEntry(row: MaintenanceRequestRow, baseline?: MaintenanceRequestRow) {
const editedEntry: EditedFieldEntry = {};
if (!baseline) {
return editedEntry;
}
for (const field of EDITABLE_FIELDS) {
if (row[field] !== baseline[field]) {
editedEntry[field] = true;
}
}
return editedEntry;
}
export function TestPlayAppView() { export function TestPlayAppView() {
const [messageApi, contextHolder] = message.useMessage();
const gridApiRef = useRef<GridReadyEvent<MaintenanceRequestRow>['api'] | null>(null);
const baselineRowsRef = useRef<Record<number, MaintenanceRequestRow>>({});
const [filters, setFilters] = useState<MaintenanceFilterState>(INITIAL_FILTERS);
const [draftFilters, setDraftFilters] = useState<MaintenanceFilterState>(INITIAL_FILTERS);
const [rows, setRows] = useState<MaintenanceRequestRow[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [hasNext, setHasNext] = useState(false);
const [editedFieldMap, setEditedFieldMap] = useState<EditedFieldMap>({});
const [editingRowId, setEditingRowId] = useState<number | null>(null);
const loadingNextPageRef = useRef(false);
const editingRow = editingRowId === null ? null : rows.find((row) => row.id === editingRowId) ?? null;
const loadedEnd = rows.length;
const loadedRangeLabel = loadedEnd > 0 ? `1-${loadedEnd.toLocaleString()} / ${total.toLocaleString()}` : `0 / ${total.toLocaleString()}`;
const pageLabel = `${page}p`;
const updateRowDraft = (rowId: number, patch: Partial<Pick<MaintenanceRequestRow, EditableField>>) => {
const currentRow = rows.find((row) => row.id === rowId);
if (!currentRow) {
return;
}
const nextRow = {
...currentRow,
...patch,
};
const editedEntry = resolveEditedFieldEntry(nextRow, baselineRowsRef.current[rowId]);
const isDirty = Object.keys(editedEntry).length > 0;
setRows((previousRows) =>
previousRows.map((row) =>
row.id === rowId
? {
...nextRow,
isDirty,
}
: row,
),
);
setEditedFieldMap((previousMap) => {
const nextMap = { ...previousMap };
if (isDirty) {
nextMap[rowId] = editedEntry;
} else {
delete nextMap[rowId];
}
return nextMap;
});
};
const loadRows = async (nextPage: number, nextFilters: MaintenanceFilterState, append = false) => {
setIsLoading(true);
if (!append) {
setEditingRowId(null);
}
try {
const response = await fetchTestAppMaintenanceRequests(nextPage, PAGE_SIZE, nextFilters);
const nextRows = response.items.map((item) => ({ ...item, isDirty: false }));
if (append) {
for (const row of nextRows) {
baselineRowsRef.current[row.id] = { ...row, isDirty: false };
}
} else {
baselineRowsRef.current = Object.fromEntries(nextRows.map((row) => [row.id, { ...row, isDirty: false }]));
setEditedFieldMap({});
}
setRows((previousRows) => (append ? [...previousRows, ...nextRows] : nextRows));
setTotal(response.total);
setPage(response.page);
setHasNext(response.hasNext);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '점검 요청 데이터를 불러오지 못했습니다.');
} finally {
setIsLoading(false);
loadingNextPageRef.current = false;
}
};
useEffect(() => {
void loadRows(1, INITIAL_FILTERS);
}, []);
useEffect(() => {
if (editingRowId === null) {
return;
}
if (!editingRow) {
setEditingRowId(null);
return;
}
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollbarWidth = Math.max(0, window.innerWidth - html.clientWidth);
html.style.overflow = 'hidden';
body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
};
}, [editingRow, editingRowId]);
useEffect(() => {
if (editingRowId === null) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setEditingRowId(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [editingRowId]);
const handleSearch = () => {
const nextFilters = {
...draftFilters,
keyword: draftFilters.keyword.trim(),
};
setFilters(nextFilters);
void loadRows(1, nextFilters);
};
const handleReset = () => {
setDraftFilters(INITIAL_FILTERS);
setFilters(INITIAL_FILTERS);
void loadRows(1, INITIAL_FILTERS);
};
const handleSave = async () => {
const dirtyRows = rows.filter((row) => row.isDirty);
if (!dirtyRows.length) {
messageApi.info('저장할 변경 행이 없습니다.');
return;
}
setIsSaving(true);
try {
await saveTestAppMaintenanceRequests(dirtyRows);
await loadRows(1, filters);
messageApi.success('저장을 완료했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!editingRow) {
return;
}
setIsDeleting(true);
try {
await deleteTestAppMaintenanceRequest(editingRow.id);
setEditingRowId(null);
await loadRows(1, filters);
messageApi.success('요청을 삭제했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
} finally {
setIsDeleting(false);
}
};
const handleBodyScrollEnd = (event: BodyScrollEndEvent<MaintenanceRequestRow>) => {
if (event.direction !== 'vertical' || loadingNextPageRef.current || isLoading || !hasNext) {
return;
}
const api = gridApiRef.current;
if (!api) {
return;
}
const range = api.getVerticalPixelRange();
const container = document.querySelector('.test-play-app__grid-surface .ag-body-viewport') as HTMLElement | null;
const remaining = container ? container.scrollHeight - range.bottom : Number.MAX_SAFE_INTEGER;
if (remaining > 80) {
return;
}
loadingNextPageRef.current = true;
void loadRows(page + 1, filters, true);
};
const columnDefs: ColDef<MaintenanceRequestRow>[] = [
{ field: 'requestNo', headerName: '작업번호', minWidth: 132, maxWidth: 148, pinned: 'left' },
{ field: 'lineCode', headerName: '라인', minWidth: 110, maxWidth: 124 },
{ field: 'equipmentName', headerName: '설비명', minWidth: 190, flex: 1.2 },
{ field: 'issueType', headerName: '이상유형', minWidth: 144, flex: 1 },
{
field: 'priority',
headerName: '우선순위',
minWidth: 108,
maxWidth: 118,
comparator: (left: MaintenancePriority, right: MaintenancePriority) => priorityRank(left) - priorityRank(right),
},
{ field: 'requesterName', headerName: '요청자', minWidth: 112, maxWidth: 128 },
{ field: 'assigneeName', headerName: '담당자', minWidth: 112, maxWidth: 128 },
{ field: 'status', headerName: '상태', minWidth: 120, maxWidth: 132 },
{ field: 'requestedAt', headerName: '요청시각', minWidth: 168, valueFormatter: formatDateTime },
{ field: 'lastActionAt', headerName: '최근조치시각', minWidth: 176, valueFormatter: formatDateTime },
];
for (const column of columnDefs) {
column.cellClass = (params) => {
const classNames = ['test-play-app__grid-cell'];
const rowId = params.data?.id ?? null;
const fieldName = params.colDef.field as EditableField | undefined;
if (rowId !== null && fieldName && editedFieldMap[rowId]?.[fieldName]) {
classNames.push('test-play-app__grid-cell--edited');
}
if (rowId !== null && editingRowId === rowId) {
classNames.push('test-play-app__grid-cell--active');
}
return classNames;
};
}
return ( return (
<div className="test-play-app"> <>
<section className="test-play-app__hero"> {contextHolder}
<div className="test-play-app__hero-copy"> <div className="test-play-app">
<Tag color="gold">Apps / Test</Tag> <section className="test-play-app__filters">
<Title level={2}> test </Title> <div className="test-play-app__heading">
<Paragraph> <strong> </strong>
Play <Text strong>Apps</Text> . layout editor와 </div>
, , .
</Paragraph>
<Space wrap>
<Button type="primary">Primary Action</Button>
<Button ghost>Secondary</Button>
</Space>
</div>
<Card className="test-play-app__spotlight" bordered={false}> <div className="test-play-app__filters-main">
<Text type="secondary">test app shell</Text> <Input
<Title level={4}> index CSS CSS </Title> className="test-play-app__search-input"
<Paragraph> value={draftFilters.keyword}
React , CSS로 . placeholder="설비명 · 작업번호 · 요청자 검색"
</Paragraph> onChange={(event) => {
<Input placeholder="다음 단계에서 앱 전용 입력/상태를 붙일 수 있습니다." /> setDraftFilters((previous) => ({
</Card> ...previous,
</section> keyword: event.target.value,
}));
}}
aria-label="검색어"
onPressEnter={handleSearch}
/>
<Row gutter={[20, 20]}> <div className="test-play-app__icon-actions">
{featureCards.map((card) => ( <Tooltip title="조회">
<Col key={card.title} xs={24} md={8}> <Button className="test-play-app__icon-button test-play-app__icon-button--primary" aria-label="조회" icon={<SearchOutlined />} type="primary" onClick={handleSearch} />
<Card className="test-play-app__feature-card" bordered={false}> </Tooltip>
<Text className="test-play-app__feature-label">{card.title}</Text> <Tooltip title={isExpanded ? '추가 필터 접기' : '추가 필터 펼치기'}>
<Paragraph>{card.description}</Paragraph> <Button
</Card> type="default"
</Col> className="test-play-app__icon-button test-play-app__filters-toggle"
))} aria-label={isExpanded ? '추가 필터 접기' : '추가 필터 펼치기'}
</Row> aria-expanded={isExpanded}
</div> icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
onClick={() => {
setIsExpanded((previous) => !previous);
}}
/>
</Tooltip>
</div>
</div>
<div className={`test-play-app__filters-detail ${isExpanded ? 'test-play-app__filters-detail--expanded' : ''}`}>
<div className="test-play-app__filters-extra-shell">
<div className="test-play-app__filters-extra">
<label className="test-play-app__filter-field">
<span></span>
<Select
value={draftFilters.lineCode}
options={LINE_OPTIONS.map((value) => ({ value, label: value }))}
onChange={(value) => {
setDraftFilters((previous) => ({
...previous,
lineCode: value,
}));
}}
/>
</label>
<label className="test-play-app__filter-field">
<span></span>
<Select
value={draftFilters.priority}
options={PRIORITY_OPTIONS.map((value) => ({ value, label: value }))}
onChange={(value) => {
setDraftFilters((previous) => ({
...previous,
priority: value,
}));
}}
/>
</label>
<label className="test-play-app__filter-field">
<span></span>
<Select
value={draftFilters.status}
options={STATUS_OPTIONS.map((value) => ({ value, label: value }))}
onChange={(value) => {
setDraftFilters((previous) => ({
...previous,
status: value,
}));
}}
/>
</label>
<label className="test-play-app__filter-field">
<span> </span>
<Input
type="date"
value={draftFilters.requestedFrom}
onChange={(event) => {
setDraftFilters((previous) => ({
...previous,
requestedFrom: event.target.value,
}));
}}
/>
</label>
<label className="test-play-app__filter-field">
<span> </span>
<Input
type="date"
value={draftFilters.requestedTo}
onChange={(event) => {
setDraftFilters((previous) => ({
...previous,
requestedTo: event.target.value,
}));
}}
/>
</label>
</div>
</div>
</div>
</section>
<section className="test-play-app__grid-panel">
<div className="test-play-app__grid-toolbar">
<div className="test-play-app__grid-meta-inline" aria-label="페이지 정보">
<span className="test-play-app__grid-meta-inline-range">{loadedRangeLabel}</span>
<span className="test-play-app__grid-meta-inline-divider" aria-hidden="true">
·
</span>
<span className="test-play-app__grid-meta-inline-page">{pageLabel}</span>
</div>
<div className="test-play-app__grid-actions">
<Tooltip title="검색조건 초기화">
<Button className="test-play-app__icon-button" aria-label="검색조건 초기화" icon={<ReloadOutlined />} onClick={handleReset} />
</Tooltip>
<Tooltip title="저장">
<Button
className="test-play-app__icon-button test-play-app__icon-button--primary"
aria-label="저장"
icon={<SaveOutlined />}
type="primary"
loading={isSaving}
onClick={() => void handleSave()}
/>
</Tooltip>
</div>
</div>
<div className="test-play-app__grid-surface ag-theme-quartz">
<Spin spinning={isLoading}>
<AgGridReact<MaintenanceRequestRow>
rowData={rows}
columnDefs={columnDefs}
defaultColDef={{
sortable: true,
resizable: true,
editable: false,
filter: false,
}}
getRowId={(params) => String(params.data.id)}
rowHeight={42}
headerHeight={42}
suppressMovableColumns
animateRows={false}
onGridReady={(event) => {
gridApiRef.current = event.api;
event.api.sizeColumnsToFit();
}}
onGridSizeChanged={() => {
gridApiRef.current?.sizeColumnsToFit();
}}
onBodyScrollEnd={handleBodyScrollEnd}
onRowDoubleClicked={(event: RowDoubleClickedEvent<MaintenanceRequestRow>) => {
if (!event.data) {
return;
}
setEditingRowId(event.data.id);
}}
rowClassRules={{
'test-play-app__grid-row--odd': (params) => ((params.node.rowIndex ?? 0) + 1) % 2 === 1,
'test-play-app__grid-row--even': (params) => ((params.node.rowIndex ?? 0) + 1) % 2 === 0,
'test-play-app__grid-row--dirty': (params) => Boolean(params.data?.isDirty),
'test-play-app__grid-row--active': (params) => params.data?.id === editingRowId,
}}
/>
</Spin>
</div>
</section>
</div>
<div className={`test-play-app__editor-shell ${editingRow ? 'test-play-app__editor-shell--open' : ''}`} aria-hidden={editingRow ? 'false' : 'true'}>
<button
type="button"
className="test-play-app__editor-backdrop"
aria-label="편집 패널 닫기"
onClick={() => {
setEditingRowId(null);
}}
/>
<section className="test-play-app__editor-panel" aria-label="점검 요청 편집 패널">
<header className="test-play-app__editor-header">
<div className="test-play-app__editor-heading">
<strong>{editingRow?.equipmentName ?? '점검 요청 편집'}</strong>
<span>{editingRow ? `${editingRow.requestNo} · ${editingRow.lineCode} · ${editingRow.issueType}` : '행 더블클릭으로 편집 패널을 엽니다.'}</span>
</div>
<div className="test-play-app__editor-actions">
<Popconfirm
title="현재 요청을 삭제할까요?"
description="삭제 후에는 목록에서 제거되고 새로고침 후에도 복구되지 않습니다."
okText="삭제"
cancelText="취소"
okButtonProps={{ danger: true, loading: isDeleting }}
onConfirm={() => void handleDelete()}
disabled={!editingRow}
>
<Tooltip title="삭제">
<Button
danger
type="default"
className="test-play-app__icon-button test-play-app__icon-button--danger test-play-app__editor-delete"
aria-label="삭제"
icon={<DeleteOutlined />}
loading={isDeleting}
disabled={!editingRow}
/>
</Tooltip>
</Popconfirm>
<Tooltip title="적용 후 닫기">
<Button
className="test-play-app__icon-button test-play-app__icon-button--primary test-play-app__editor-apply"
aria-label="적용 후 닫기"
icon={<CheckOutlined />}
type="primary"
disabled={!editingRow}
onClick={() => {
if (!editingRow) {
return;
}
setEditingRowId(null);
}}
/>
</Tooltip>
<Tooltip title="닫기">
<Button
type="default"
className="test-play-app__icon-button test-play-app__editor-close"
aria-label="닫기"
icon={<CloseOutlined />}
onClick={() => {
setEditingRowId(null);
}}
/>
</Tooltip>
</div>
</header>
<div className="test-play-app__editor-body">
<section className="test-play-app__editor-summary">
<div className="test-play-app__editor-card">
<span className="test-play-app__editor-card-label"></span>
<strong>{editingRow?.requestNo ?? '-'}</strong>
</div>
<div className="test-play-app__editor-card">
<span className="test-play-app__editor-card-label"></span>
<strong>{editingRow?.requesterName ?? '-'}</strong>
</div>
<div className="test-play-app__editor-card">
<span className="test-play-app__editor-card-label"></span>
<strong>{editingRow?.requestedAt ? editingRow.requestedAt.replace('T', ' ').slice(0, 16) : '-'}</strong>
</div>
<div className="test-play-app__editor-card">
<span className="test-play-app__editor-card-label"></span>
<strong>{editingRow?.lastActionAt ? editingRow.lastActionAt.replace('T', ' ').slice(0, 16) : '-'}</strong>
</div>
</section>
<section className="test-play-app__editor-form">
<div className="test-play-app__editor-form-grid">
<label className="test-play-app__editor-field">
<span></span>
<Input value={editingRow?.equipmentName ?? ''} readOnly />
</label>
<label className="test-play-app__editor-field">
<span></span>
<Input value={editingRow?.issueType ?? ''} readOnly />
</label>
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.priority ? 'test-play-app__editor-field--edited' : ''}`}>
<span></span>
<Select
value={editingRow?.priority}
options={EDITABLE_PRIORITY_OPTIONS.map((value) => ({ value, label: value }))}
disabled={!editingRow}
onChange={(value) => {
if (!editingRow) {
return;
}
updateRowDraft(editingRow.id, { priority: value });
}}
/>
</label>
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.status ? 'test-play-app__editor-field--edited' : ''}`}>
<span></span>
<Select
value={editingRow?.status}
options={EDITABLE_STATUS_OPTIONS.map((value) => ({ value, label: value }))}
disabled={!editingRow}
onChange={(value) => {
if (!editingRow) {
return;
}
updateRowDraft(editingRow.id, { status: value });
}}
/>
</label>
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.assigneeName ? 'test-play-app__editor-field--edited' : ''}`}>
<span></span>
<Input
value={editingRow?.assigneeName ?? ''}
placeholder="담당자를 입력하세요."
disabled={!editingRow}
onChange={(event) => {
if (!editingRow) {
return;
}
updateRowDraft(editingRow.id, { assigneeName: event.target.value });
}}
/>
</label>
<div className="test-play-app__editor-note">
<strong> </strong>
<span> , , . .</span>
</div>
</div>
</section>
</div>
</section>
</div>
</>
); );
} }

View File

@@ -0,0 +1,260 @@
import { appendClientIdHeader } from '../../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
const WORK_SERVER_TIMEOUT_MS = 10000;
export type TestAppMaintenanceRequestRow = {
id: number;
requestNo: string;
lineCode: string;
equipmentName: string;
issueType: string;
priority: '긴급' | '높음' | '보통' | '낮음';
requesterName: string;
assigneeName: string;
status: '접수' | '배정완료' | '조치중' | '부품대기' | '완료';
requestedAt: string;
lastActionAt: string;
isDirty?: boolean;
};
export type TestAppMaintenanceRequestFilters = {
keyword: string;
lineCode: string;
priority: string;
status: string;
requestedFrom: string;
requestedTo: string;
};
export type TestAppMaintenanceRequestListResponse = {
ok: true;
items: TestAppMaintenanceRequestRow[];
page: number;
pageSize: number;
total: number;
hasNext: boolean;
filters: {
keyword: string;
lineCode: string;
priority: string;
status: string;
requestedFrom: string | null;
requestedTo: string | null;
};
};
export type TestAppLegacyStatus = '대기' | '진행' | '완료' | '점검필요';
export type TestAppGridRow = {
id: number;
pressureWindow: string;
status: TestAppLegacyStatus;
measuredValue: number;
createdAt: string;
updatedAt: string;
isDirty?: boolean;
};
export type TestAppFilters = {
pressureWindow: string;
status: string;
minMeasuredValue: string;
maxMeasuredValue: string;
dateFrom: string;
dateTo: string;
};
export type TestAppListResponse = {
ok: true;
items: TestAppGridRow[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
filters: {
pressureWindow: string;
status: string;
minMeasuredValue: number | null;
maxMeasuredValue: number | null;
dateFrom: string | null;
dateTo: string | null;
};
};
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
async function request<T>(path: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
if (init?.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
try {
const response = await fetch(`${WORK_SERVER_BASE_URL}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? 'no-store',
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new Error(payload.message || 'Test App 요청에 실패했습니다.');
} catch {
throw new Error(text || 'Test App 요청에 실패했습니다.');
}
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
function toLegacyRow(item: TestAppMaintenanceRequestRow): TestAppGridRow {
return {
pressureWindow: `${item.equipmentName} / ${item.requestNo}`,
status:
item.status === '접수'
? '대기'
: item.status === '배정완료'
? '점검필요'
: item.status === '조치중'
? '진행'
: '완료',
measuredValue:
item.priority === '긴급'
? 95
: item.priority === '높음'
? 78
: item.priority === '보통'
? 54
: 28,
createdAt: item.requestedAt,
updatedAt: item.lastActionAt,
id: item.id,
};
}
export async function fetchTestAppMaintenanceRequests(
page: number,
pageSize: number,
filters: TestAppMaintenanceRequestFilters,
) {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
});
if (filters.keyword.trim()) {
params.set('keyword', filters.keyword.trim());
}
if (filters.lineCode && filters.lineCode !== '전체') {
params.set('lineCode', filters.lineCode);
}
if (filters.priority && filters.priority !== '전체') {
params.set('priority', filters.priority);
}
if (filters.status && filters.status !== '전체') {
params.set('status', filters.status);
}
if (filters.requestedFrom) {
params.set('requestedFrom', filters.requestedFrom);
}
if (filters.requestedTo) {
params.set('requestedTo', filters.requestedTo);
}
return request<TestAppMaintenanceRequestListResponse>(`/test-app/maintenance-requests?${params.toString()}`);
}
export async function saveTestAppMaintenanceRequests(items: TestAppMaintenanceRequestRow[]) {
return request<{ ok: true; count: number; items: TestAppMaintenanceRequestRow[] }>('/test-app/maintenance-requests', {
method: 'PUT',
body: JSON.stringify({
items: items.map((item) => ({
id: item.id,
priority: item.priority,
status: item.status,
assigneeName: item.assigneeName,
})),
}),
});
}
export async function deleteTestAppMaintenanceRequest(id: number) {
return request<{ ok: true; deletedId: number; item: TestAppMaintenanceRequestRow }>(`/test-app/maintenance-requests/${id}`, {
method: 'DELETE',
});
}
export async function fetchTestAppMeasurements(page: number, pageSize: number, filters: TestAppFilters) {
const response = await fetchTestAppMaintenanceRequests(page, pageSize, {
keyword: filters.pressureWindow,
lineCode: '전체',
priority: '전체',
status: filters.status,
requestedFrom: filters.dateFrom,
requestedTo: filters.dateTo,
});
return {
ok: true,
items: response.items.map(toLegacyRow),
pagination: {
page: response.page,
pageSize: response.pageSize,
total: response.total,
totalPages: Math.max(1, Math.ceil(response.total / response.pageSize)),
},
filters: {
pressureWindow: response.filters.keyword,
status: response.filters.status,
minMeasuredValue: null,
maxMeasuredValue: null,
dateFrom: response.filters.requestedFrom,
dateTo: response.filters.requestedTo,
},
} satisfies TestAppListResponse;
}
export async function saveTestAppMeasurements(items: TestAppGridRow[]) {
return request<{ ok: true; count: number; items: TestAppMaintenanceRequestRow[] }>('/test-app/maintenance-requests', {
method: 'PUT',
body: JSON.stringify({
items: items.map((item) => ({
id: item.id,
priority: item.measuredValue >= 85 ? '긴급' : item.measuredValue >= 65 ? '높음' : item.measuredValue >= 40 ? '보통' : '낮음',
status: item.status === '대기' ? '접수' : item.status === '점검필요' ? '배정완료' : item.status === '진행' ? '조치중' : '완료',
assigneeName: item.status === '대기' ? '' : '호환저장',
})),
}),
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,683 @@
export type EquipmentSlot =
| 'weapon'
| 'helmet'
| 'shoulders'
| 'chest'
| 'belt'
| 'gloves'
| 'boots'
| 'ring'
| 'necklace'
| 'bracelet'
| 'artifact';
export type InventoryItem = {
id: string;
name: string;
category: 'weapon' | 'armor' | 'accessory' | 'consumable' | 'material';
rarity: 'common' | 'rare' | 'epic';
icon: string;
description: string;
attack?: number;
defense?: number;
critRate?: number;
spellPower?: number;
maxMpBonus?: number;
manaAbsorbChance?: number;
heal?: number;
mpRestore?: number;
healOverTimeTotal?: number;
healOverTimeTicks?: number;
healOverTimeIntervalMs?: number;
autoHealMultiplier?: number;
moveSpeedMultiplier?: number;
effectDurationMs?: number;
quantity?: number;
slot?: EquipmentSlot;
};
export type SkillDefinition = {
id: string;
name: string;
description: string;
manaCost: number;
cooldownMs: number;
damage: number;
range: number;
castTimeMs: number;
accentLabel: string;
color: string;
icon: string;
role: 'attack' | 'buff' | 'summon';
durationMs?: number;
};
export type QuestDefinition = {
id: string;
title: string;
description: string;
progressType: 'kill' | 'collect';
target: string;
requiredCount: number;
turnInNpcId: string;
turnInItemId?: string;
rewardGold: number;
rewardExp: number;
rewardItemId?: string;
};
export type NpcDefinition = {
id: string;
name: string;
role: 'quest' | 'shop' | 'guide';
description: string;
x: number;
y: number;
};
export type EnemyDefinition = {
id: string;
name: string;
role: 'normal' | 'boss';
level: number;
maxHp: number;
maxMp: number;
attack: number;
expReward: number;
goldReward: number;
lootTable: Array<{
itemId: string;
chance: number;
minQuantity?: number;
maxQuantity?: number;
}>;
x: number;
y: number;
patrolRadius: number;
tint: number;
};
export const THE_QUEST_WORLD_SIZE = {
width: 2200,
height: 1600,
} as const;
export const THE_QUEST_STAGE_PORTAL = {
x: 1870,
y: 258,
radius: 118,
} as const;
export const THE_QUEST_SKILLS: SkillDefinition[] = [
{
id: 'energy-bolt',
name: '에너지 볼트',
description: '짧은 쿨다운으로 마력 탄환을 날려 기본 견제를 이어 갑니다.',
manaCost: 0,
cooldownMs: 640,
damage: 16,
range: 214,
castTimeMs: 0,
accentLabel: '기본기',
color: '#9ecfff',
icon: '✦',
role: 'attack',
},
{
id: 'fire-arrow',
name: '파이어 애로우',
description: '시전이 빠른 화염 탄환으로 체력을 깎아 두기 좋은 견제 마법입니다.',
manaCost: 10,
cooldownMs: 2100,
damage: 34,
range: 224,
castTimeMs: 120,
accentLabel: '화염',
color: '#ffb06f',
icon: '➶',
role: 'attack',
},
{
id: 'ice-dagger',
name: '아이스 대거',
description: '날카로운 냉기 단검을 날려 강한 단일 피해를 줍니다.',
manaCost: 16,
cooldownMs: 3200,
damage: 52,
range: 238,
castTimeMs: 180,
accentLabel: '빙결',
color: '#8fe7ff',
icon: '❄',
role: 'attack',
},
{
id: 'lightning',
name: '라이트닝',
description: '가까운 적을 기준으로 번개를 연쇄시켜 다수를 태웁니다.',
manaCost: 24,
cooldownMs: 5200,
damage: 46,
range: 244,
castTimeMs: 220,
accentLabel: '연쇄',
color: '#ffd36f',
icon: '⚡',
role: 'attack',
},
{
id: 'fireball',
name: '파이어볼',
description: '폭발하는 화염구를 떨어뜨려 반경 적을 한 번에 태웁니다.',
manaCost: 30,
cooldownMs: 7600,
damage: 68,
range: 182,
castTimeMs: 280,
accentLabel: '광역',
color: '#ff9a6a',
icon: '☄',
role: 'attack',
},
{
id: 'greater-haste',
name: '그레이터 헤이스트',
description: '이동과 시전 리듬을 끌어올려 위험한 구간을 빠르게 돌파합니다.',
manaCost: 22,
cooldownMs: 11800,
damage: 0,
range: 0,
castTimeMs: 120,
accentLabel: '가속',
color: '#d4c0ff',
icon: '⟡',
role: 'buff',
durationMs: 10000,
},
{
id: 'magic-shield',
name: '매직 실드',
description: '마력 보호막으로 받는 피해를 줄이며 난전을 버팁니다.',
manaCost: 26,
cooldownMs: 12600,
damage: 0,
range: 0,
castTimeMs: 120,
accentLabel: '보호',
color: '#7fcfff',
icon: '⬡',
role: 'buff',
durationMs: 9200,
},
{
id: 'summon-skeleton',
name: '서먼 스켈레톤',
description: '언데드 하수인을 불러 주변 적을 압박하고 마나 순환을 돕습니다.',
manaCost: 34,
cooldownMs: 15000,
damage: 30,
range: 168,
castTimeMs: 180,
accentLabel: '소환',
color: '#d8f0ff',
icon: '☠',
role: 'summon',
durationMs: 15000,
},
];
export const THE_QUEST_ITEMS: InventoryItem[] = [
{
id: 'apprentice-staff',
name: '수습 마도 지팡이',
category: 'weapon',
rarity: 'rare',
icon: '🪄',
description: '기본 주문 위력을 끌어올리고 낮은 확률로 적 MP를 흡수합니다.',
attack: 8,
spellPower: 12,
maxMpBonus: 16,
manaAbsorbChance: 0.16,
slot: 'weapon',
},
{
id: 'scribe-hood',
name: '스크라이브 후드',
category: 'armor',
rarity: 'common',
icon: '🪖',
description: '집중을 돕는 천 후드. 치명과 MP를 살짝 높입니다.',
defense: 4,
critRate: 0.02,
maxMpBonus: 10,
slot: 'helmet',
},
{
id: 'astral-mantle',
name: '아스트랄 맨틀',
category: 'armor',
rarity: 'rare',
icon: '🧥',
description: '별가루를 엮은 로브. 주문 저항과 MP 풀을 함께 보강합니다.',
defense: 8,
maxMpBonus: 18,
slot: 'chest',
},
{
id: 'moon-spaulders',
name: '문 스폴더',
category: 'armor',
rarity: 'rare',
icon: '🪽',
description: '달빛 견갑. 치명 감각과 주문 회전을 보조합니다.',
defense: 4,
critRate: 0.03,
spellPower: 4,
slot: 'shoulders',
},
{
id: 'rune-gloves',
name: '룬 글러브',
category: 'armor',
rarity: 'common',
icon: '🧤',
description: '시전 안정화를 돕는 장갑. 주문 위력이 조금 오릅니다.',
spellPower: 6,
slot: 'gloves',
},
{
id: 'mist-belt',
name: '미스트 벨트',
category: 'armor',
rarity: 'common',
icon: '🧷',
description: '마력 응축 벨트. 방어와 MP를 함께 보정합니다.',
defense: 3,
maxMpBonus: 12,
slot: 'belt',
},
{
id: 'trail-boots',
name: '트레일 부츠',
category: 'armor',
rarity: 'common',
icon: '🥾',
description: '이동용 부츠. 기본 방어를 보강합니다.',
defense: 3,
slot: 'boots',
},
{
id: 'moon-ring',
name: '문링',
category: 'accessory',
rarity: 'epic',
icon: '💍',
description: '달의 조율 반지. 치명과 주문 위력을 크게 높입니다.',
critRate: 0.08,
spellPower: 8,
slot: 'ring',
},
{
id: 'ember-bracelet',
name: '엠버 브레이슬릿',
category: 'accessory',
rarity: 'rare',
icon: '📿',
description: '주문 가속 팔찌. MP와 주문 위력을 함께 높입니다.',
spellPower: 5,
maxMpBonus: 14,
slot: 'bracelet',
},
{
id: 'forest-relic',
name: '포레스트 렐릭',
category: 'accessory',
rarity: 'epic',
icon: '🜂',
description: '숲의 비전이 담긴 유물. 전반적인 마도사 성능을 끌어올립니다.',
attack: 4,
defense: 4,
spellPower: 6,
critRate: 0.04,
slot: 'artifact',
},
{
id: 'moonbranch-staff',
name: '문브랜치 스태프',
category: 'weapon',
rarity: 'rare',
icon: '🪄',
description: '달가지 지팡이. 주문 위력과 마나 흡수 확률이 더 높습니다.',
attack: 10,
spellPower: 16,
maxMpBonus: 20,
manaAbsorbChance: 0.22,
slot: 'weapon',
},
{
id: 'slimecore-cane',
name: '슬라임코어 케인',
category: 'weapon',
rarity: 'epic',
icon: '🔮',
description: '슬라임 코어를 박아 넣은 지팡이. 주문 위력과 흡수량이 크게 오릅니다.',
attack: 12,
spellPower: 20,
maxMpBonus: 28,
manaAbsorbChance: 0.28,
slot: 'weapon',
},
{
id: 'moss-guard',
name: '모스 가드',
category: 'armor',
rarity: 'rare',
icon: '🥋',
description: '이끼 섬유를 엮은 외투. 생존력과 MP를 함께 올립니다.',
defense: 12,
maxMpBonus: 16,
slot: 'chest',
},
{
id: 'owl-visor',
name: '아울 바이저',
category: 'armor',
rarity: 'rare',
icon: '⛑',
description: '시야 보정 바이저. 방어와 치명을 함께 올립니다.',
defense: 6,
critRate: 0.03,
slot: 'helmet',
},
{
id: 'river-striders',
name: '리버 스트라이더',
category: 'armor',
rarity: 'rare',
icon: '👢',
description: '습지 돌파 장화. 안정적인 하체 방어를 제공합니다.',
defense: 5,
slot: 'boots',
},
{
id: 'river-charm',
name: '리버 참',
category: 'accessory',
rarity: 'epic',
icon: '🪬',
description: '강의 기운이 담긴 부적. MP와 치명타 확률을 높입니다.',
critRate: 0.1,
maxMpBonus: 24,
slot: 'necklace',
},
{
id: 'sun-band',
name: '선 밴드',
category: 'accessory',
rarity: 'rare',
icon: '💠',
description: '햇빛 결정 보조 반지. 주문 위력이 추가로 오릅니다.',
spellPower: 7,
slot: 'ring',
},
{
id: 'mist-pendant',
name: '미스트 펜던트',
category: 'accessory',
rarity: 'epic',
icon: '📿',
description: '안개 정수 목걸이. 방어와 MP를 동시에 보강합니다.',
defense: 4,
maxMpBonus: 26,
critRate: 0.05,
slot: 'necklace',
},
{
id: 'hp-potion',
name: '체력 포션',
category: 'consumable',
rarity: 'common',
icon: '🧪',
description: '체력을 순차적으로 180 회복합니다.',
healOverTimeTotal: 180,
healOverTimeTicks: 6,
healOverTimeIntervalMs: 900,
quantity: 5,
},
{
id: 'auto-heal-elixir',
name: '특수 회복 엘릭서',
category: 'consumable',
rarity: 'rare',
icon: '✨',
description: '18초 동안 자동 회복량을 2.5배로 끌어올립니다.',
autoHealMultiplier: 2.5,
effectDurationMs: 18000,
quantity: 2,
},
{
id: 'celerity-potion',
name: '가속 물약',
category: 'consumable',
rarity: 'rare',
icon: '⚡',
description: '14초 동안 모든 이동 속도를 2배로 가속합니다.',
moveSpeedMultiplier: 2,
effectDurationMs: 14000,
quantity: 2,
},
{
id: 'slime-gel',
name: '슬라임 젤',
category: 'material',
rarity: 'common',
icon: '🫧',
description: '연금술 재료. 퀘스트와 촉매 제작에 사용됩니다.',
quantity: 0,
},
];
export const THE_QUEST_QUESTS: QuestDefinition[] = [
{
id: 'slime-hunt',
title: '[메인] 푸른 숲의 정화',
description: '포탈 근처 슬라임 8마리를 정리한 뒤 기사단장 브롬에게 보고하세요.',
progressType: 'kill',
target: 'slime',
requiredCount: 8,
turnInNpcId: 'brom',
rewardGold: 120,
rewardExp: 90,
rewardItemId: 'hp-potion',
},
{
id: 'slime-gel',
title: '[서브] 연금 재료 수집',
description: '슬라임 젤 12개를 모은 뒤 상인 미라에게 직접 전달하세요.',
progressType: 'collect',
target: 'slime-gel',
requiredCount: 12,
turnInNpcId: 'mira',
turnInItemId: 'slime-gel',
rewardGold: 160,
rewardExp: 120,
rewardItemId: 'celerity-potion',
},
];
export const THE_QUEST_NPCS: NpcDefinition[] = [
{
id: 'lyra',
name: '리라',
role: 'guide',
description: '초반 진행과 조작을 안내하는 길드 안내원.',
x: 1080,
y: 730,
},
{
id: 'brom',
name: '브롬',
role: 'quest',
description: '사냥 퀘스트를 제공하는 기사단장.',
x: 1470,
y: 960,
},
{
id: 'mira',
name: '미라',
role: 'shop',
description: '포션과 장비 재료를 취급하는 상인.',
x: 880,
y: 1160,
},
];
export function createInitialEnemies(): EnemyDefinition[] {
return [
{
id: 'slime-1',
name: '슬라임',
role: 'normal',
level: 10,
maxHp: 250,
maxMp: 26,
attack: 18,
expReward: 28,
goldReward: 16,
lootTable: [
{ itemId: 'slime-gel', chance: 1, minQuantity: 1, maxQuantity: 2 },
{ itemId: 'hp-potion', chance: 0.24, minQuantity: 1, maxQuantity: 1 },
{ itemId: 'moonbranch-staff', chance: 0.08, minQuantity: 1, maxQuantity: 1 },
],
x: 1260,
y: 760,
patrolRadius: 72,
tint: 0x6fd0ff,
},
{
id: 'slime-2',
name: '슬라임',
role: 'normal',
level: 10,
maxHp: 250,
maxMp: 24,
attack: 18,
expReward: 28,
goldReward: 16,
lootTable: [
{ itemId: 'slime-gel', chance: 1, minQuantity: 1, maxQuantity: 2 },
{ itemId: 'celerity-potion', chance: 0.18, minQuantity: 1, maxQuantity: 1 },
{ itemId: 'moss-guard', chance: 0.08, minQuantity: 1, maxQuantity: 1 },
],
x: 1410,
y: 900,
patrolRadius: 84,
tint: 0x7be08f,
},
{
id: 'slime-3',
name: '엘더 슬라임',
role: 'normal',
level: 11,
maxHp: 310,
maxMp: 42,
attack: 20,
expReward: 32,
goldReward: 18,
lootTable: [
{ itemId: 'slime-gel', chance: 1, minQuantity: 2, maxQuantity: 3 },
{ itemId: 'hp-potion', chance: 0.3, minQuantity: 1, maxQuantity: 1 },
{ itemId: 'moonbranch-staff', chance: 0.12, minQuantity: 1, maxQuantity: 1 },
],
x: 1540,
y: 840,
patrolRadius: 80,
tint: 0x86d0ff,
},
{
id: 'slime-4',
name: '문글림 슬라임',
role: 'normal',
level: 11,
maxHp: 320,
maxMp: 56,
attack: 21,
expReward: 32,
goldReward: 18,
lootTable: [
{ itemId: 'slime-gel', chance: 1, minQuantity: 2, maxQuantity: 3 },
{ itemId: 'auto-heal-elixir', chance: 0.24, minQuantity: 1, maxQuantity: 1 },
{ itemId: 'river-charm', chance: 0.1, minQuantity: 1, maxQuantity: 1 },
],
x: 1360,
y: 1060,
patrolRadius: 78,
tint: 0x7ce8ff,
},
{
id: 'slime-5',
name: '가디언 슬라임',
role: 'normal',
level: 12,
maxHp: 390,
maxMp: 64,
attack: 24,
expReward: 40,
goldReward: 20,
lootTable: [
{ itemId: 'slime-gel', chance: 1, minQuantity: 2, maxQuantity: 4 },
{ itemId: 'hp-potion', chance: 0.3, minQuantity: 1, maxQuantity: 2 },
{ itemId: 'slimecore-cane', chance: 0.18, minQuantity: 1, maxQuantity: 1 },
{ itemId: 'moss-guard', chance: 0.14, minQuantity: 1, maxQuantity: 1 },
],
x: 1670,
y: 970,
patrolRadius: 90,
tint: 0x65d6ff,
},
{
id: 'slime-6',
name: '에메랄드 슬라임',
role: 'normal',
level: 12,
maxHp: 380,
maxMp: 52,
attack: 23,
expReward: 40,
goldReward: 20,
lootTable: [
{ itemId: 'slime-gel', chance: 1, minQuantity: 2, maxQuantity: 4 },
{ itemId: 'celerity-potion', chance: 0.3, minQuantity: 1, maxQuantity: 1 },
{ itemId: 'moonbranch-staff', chance: 0.12, minQuantity: 1, maxQuantity: 1 },
{ itemId: 'river-charm', chance: 0.14, minQuantity: 1, maxQuantity: 1 },
],
x: 1210,
y: 1180,
patrolRadius: 96,
tint: 0x80f0b0,
},
{
id: 'stage-boss',
name: '포탈 수호자 크라운 슬라임',
role: 'boss',
level: 14,
maxHp: 920,
maxMp: 180,
attack: 38,
expReward: 120,
goldReward: 88,
lootTable: [
{ itemId: 'slime-gel', chance: 1, minQuantity: 4, maxQuantity: 6 },
{ itemId: 'hp-potion', chance: 0.75, minQuantity: 2, maxQuantity: 3 },
{ itemId: 'auto-heal-elixir', chance: 0.54, minQuantity: 1, maxQuantity: 2 },
{ itemId: 'celerity-potion', chance: 0.58, minQuantity: 1, maxQuantity: 2 },
{ itemId: 'mist-pendant', chance: 0.24, minQuantity: 1, maxQuantity: 1 },
{ itemId: 'slimecore-cane', chance: 0.28, minQuantity: 1, maxQuantity: 1 },
],
x: 1820,
y: 360,
patrolRadius: 64,
tint: 0xffb86a,
},
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
commitLayoutDrawHistory,
createLayoutDrawHistoryState,
redoLayoutDrawHistory,
undoLayoutDrawHistory,
} from '../../src/features/layout/draw/layoutDrawHistory.ts';
import type { LayoutDrawDocument } from '../../src/features/layout/draw/layoutDrawTypes.ts';
function createDocument(partial?: Partial<LayoutDrawDocument>): LayoutDrawDocument {
return {
backgroundMode: partial?.backgroundMode ?? 'grid',
shapes:
partial?.shapes ?? [
{
id: 'line-1',
type: 'line',
x1: 10,
y1: 20,
x2: 10,
y2: 180,
orientation: 'vertical',
label: '',
},
],
};
}
test('commits a new snapshot into undo history and clears redo history', () => {
const initial = createLayoutDrawHistoryState(createDocument({ shapes: [] }));
const committed = commitLayoutDrawHistory(initial, createDocument());
assert.equal(committed.past.length, 1);
assert.equal(committed.future.length, 0);
assert.equal(committed.present.shapes.length, 1);
});
test('undo restores the previous snapshot and redo reapplies it', () => {
const initial = createLayoutDrawHistoryState(createDocument({ shapes: [] }));
const committed = commitLayoutDrawHistory(initial, createDocument());
const undone = undoLayoutDrawHistory(committed);
const redone = redoLayoutDrawHistory(undone);
assert.equal(undone.present.shapes.length, 0);
assert.equal(redone.present.shapes.length, 1);
});
test('does not create duplicate history entries for unchanged documents', () => {
const initialDocument = createDocument();
const initial = createLayoutDrawHistoryState(initialDocument);
const committed = commitLayoutDrawHistory(initial, initialDocument);
assert.equal(committed, initial);
assert.equal(committed.past.length, 0);
});

View File

@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { findRegionAtPoint, resolveDrawRegions } from '../../src/features/layout/draw/layoutDrawRegions.ts';
import type { DrawShape } from '../../src/features/layout/draw/layoutDrawTypes.ts';
function createSplitShapes(): DrawShape[] {
return [
{
id: 'line-vertical',
type: 'line',
x1: 100,
y1: 0,
x2: 100,
y2: 200,
orientation: 'vertical',
label: '',
},
{
id: 'line-horizontal',
type: 'line',
x1: 0,
y1: 120,
x2: 100,
y2: 120,
orientation: 'horizontal',
label: '',
},
];
}
test('resolves line-divided empty areas into separate selectable regions', () => {
const regions = resolveDrawRegions(createSplitShapes(), 200, 200);
assert.equal(regions.length, 3);
assert.equal(findRegionAtPoint(regions, 50, 60)?.key, regions[0]?.key);
assert.equal(findRegionAtPoint(regions, 50, 160)?.key, regions[1]?.key);
assert.equal(findRegionAtPoint(regions, 150, 80)?.key, regions[2]?.key);
});
test('maps stored region labels and fill colors back onto the computed region', () => {
const baseShapes = createSplitShapes();
const baseRegions = resolveDrawRegions(baseShapes, 200, 200);
const leftTopRegion = findRegionAtPoint(baseRegions, 50, 60);
assert.ok(leftTopRegion);
const regions = resolveDrawRegions(
[
...baseShapes,
{
id: 'region-1',
type: 'region',
regionKey: leftTopRegion.key,
label: '거실',
fillColor: '#bfdbfe',
},
],
200,
200,
);
const resolved = findRegionAtPoint(regions, 50, 60);
assert.equal(resolved?.label, '거실');
assert.equal(resolved?.fillColor, '#bfdbfe');
});

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clampSelectionRect,
findShapesInSelection,
rebaseShapesToComponentBlueprint,
resolveGroupedShapeIds,
} from '../../src/features/layout/draw/layoutDrawSelectionUtils.ts';
import type { DrawableShape } from '../../src/features/layout/draw/layoutDrawTypes.ts';
test('expands grouped selection from any seed id', () => {
const shapes: DrawableShape[] = [
{ id: 'a', type: 'rect', x: 0, y: 0, width: 10, height: 10, label: '', groupId: 'g1' },
{ id: 'b', type: 'rect', x: 20, y: 0, width: 10, height: 10, label: '', groupId: 'g1' },
{ id: 'c', type: 'rect', x: 40, y: 0, width: 10, height: 10, label: '' },
];
assert.deepEqual([...resolveGroupedShapeIds(shapes, ['a'])], ['a', 'b']);
});
test('finds shapes intersecting drag selection bounds', () => {
const shapes: DrawableShape[] = [
{ id: 'a', type: 'rect', x: 10, y: 10, width: 50, height: 50, label: '' },
{ id: 'b', type: 'line', x1: 80, y1: 20, x2: 140, y2: 20, orientation: 'horizontal', label: '' },
{ id: 'c', type: 'rect', x: 200, y: 200, width: 20, height: 20, label: '' },
];
const selection = clampSelectionRect(0, 0, 120, 80);
assert.deepEqual(
findShapesInSelection(shapes, selection).map((shape) => shape.id),
['a', 'b'],
);
});
test('rebases saved component shapes to local coordinates', () => {
const shapes: DrawableShape[] = [
{ id: 'a', type: 'rect', x: 100, y: 40, width: 30, height: 20, label: 'A' },
{ id: 'b', type: 'line', x1: 120, y1: 60, x2: 180, y2: 60, orientation: 'horizontal', label: 'B' },
];
assert.deepEqual(rebaseShapesToComponentBlueprint(shapes), [
{ id: 'a', type: 'rect', x: 0, y: 0, width: 30, height: 20, label: 'A' },
{ id: 'b', type: 'line', x1: 20, y1: 20, x2: 80, y2: 20, orientation: 'horizontal', label: 'B' },
]);
});

View File

@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { duplicateShapeWithLabel } from '../../src/features/layout/draw/layoutDrawShapeUtils.ts';
test('duplicates a line with original label when no override is provided', () => {
const duplicated = duplicateShapeWithLabel(
{
id: 'line-1',
type: 'line',
x1: 10,
y1: 20,
x2: 10,
y2: 160,
orientation: 'vertical',
label: '기존',
},
'line-2',
);
assert.deepEqual(duplicated, {
id: 'line-2',
type: 'line',
x1: 34,
y1: 44,
x2: 34,
y2: 184,
orientation: 'vertical',
label: '기존',
});
});
test('duplicates a line with label override and offset', () => {
const duplicated = duplicateShapeWithLabel(
{
id: 'line-1',
type: 'line',
x1: 10,
y1: 20,
x2: 10,
y2: 160,
orientation: 'vertical',
label: '기존',
},
'line-2',
'복사본',
);
assert.deepEqual(duplicated, {
id: 'line-2',
type: 'line',
x1: 34,
y1: 44,
x2: 34,
y2: 184,
orientation: 'vertical',
label: '복사본',
});
});
test('duplicates a rect with label override and offset', () => {
const duplicated = duplicateShapeWithLabel(
{
id: 'rect-1',
type: 'rect',
x: 40,
y: 50,
width: 120,
height: 80,
label: '사각형',
fillColor: '#bfdbfe',
},
'rect-2',
'새 라벨',
);
assert.deepEqual(duplicated, {
id: 'rect-2',
type: 'rect',
x: 64,
y: 74,
width: 120,
height: 80,
label: '새 라벨',
fillColor: '#bfdbfe',
});
});

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeSavedLayoutDrawShapes,
serializeSavedLayoutDrawShapes,
} from '../../src/features/layout/draw/layoutDrawStorageShapes.ts';
import type { DrawShape } from '../../src/features/layout/draw/layoutDrawTypes.ts';
function createShapes(): DrawShape[] {
return [
{
id: 'line-1',
type: 'line',
x1: 0,
y1: 0,
x2: 0,
y2: 120,
orientation: 'vertical',
label: '',
},
{
id: 'region-1',
type: 'region',
regionKey: '0:0',
label: '거실',
fillColor: '#dbeafe',
},
];
}
test('serializes saved draw shapes into a JSON string for jsonb insert payloads', () => {
const serialized = serializeSavedLayoutDrawShapes(createShapes());
assert.equal(typeof serialized, 'string');
assert.deepEqual(JSON.parse(serialized), createShapes());
});
test('normalizes stringified jsonb array values back into shape arrays', () => {
const normalized = normalizeSavedLayoutDrawShapes(JSON.stringify(createShapes()));
assert.deepEqual(normalized, createShapes());
});
test('falls back to an empty array for malformed saved draw shapes', () => {
assert.deepEqual(normalizeSavedLayoutDrawShapes('{bad json'), []);
});

View File

@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveLineDraft, type DrawShapeLike } from '../../src/features/layout/draw/lineDraft.ts';
const canvasWidth = 500;
const canvasHeight = 300;
function line(shape: Omit<Extract<DrawShapeLike, { type: 'line' }>, 'type'>): DrawShapeLike {
return { type: 'line', ...shape };
}
function rect(): DrawShapeLike {
return { type: 'rect', x: 40, y: 40, width: 80, height: 60 };
}
test('creates a vertical divider from a horizontal gesture when no horizontal crossing line exists ahead', () => {
assert.deepEqual(resolveLineDraft(120, 80, 300, 90, [], canvasWidth, canvasHeight), {
startX: 120,
startY: 0,
endX: 120,
endY: canvasHeight,
orientation: 'vertical',
});
assert.deepEqual(resolveLineDraft(120, 80, 10, 70, [], canvasWidth, canvasHeight), {
startX: 120,
startY: 0,
endX: 120,
endY: canvasHeight,
orientation: 'vertical',
});
});
test('creates a horizontal divider from a vertical gesture when no vertical crossing line exists ahead', () => {
assert.deepEqual(resolveLineDraft(120, 80, 130, 220, [], canvasWidth, canvasHeight), {
startX: 0,
startY: 80,
endX: canvasWidth,
endY: 80,
orientation: 'horizontal',
});
assert.deepEqual(resolveLineDraft(120, 80, 110, 10, [], canvasWidth, canvasHeight), {
startX: 0,
startY: 80,
endX: canvasWidth,
endY: 80,
orientation: 'horizontal',
});
});
test('stops at the nearest horizontal crossing line for a horizontal gesture divider', () => {
const shapes: DrawShapeLike[] = [
line({ x1: 20, y1: 40, x2: 220, y2: 40, orientation: 'horizontal' }),
line({ x1: 40, y1: 180, x2: 260, y2: 180, orientation: 'horizontal' }),
];
assert.deepEqual(resolveLineDraft(120, 80, 480, 90, shapes, canvasWidth, canvasHeight), {
startX: 120,
startY: 40,
endX: 120,
endY: 180,
orientation: 'vertical',
});
});
test('stops at the nearest vertical crossing line for a vertical gesture divider', () => {
const shapes: DrawShapeLike[] = [
line({ x1: 60, y1: 20, x2: 60, y2: 180, orientation: 'vertical' }),
line({ x1: 260, y1: 20, x2: 260, y2: 160, orientation: 'vertical' }),
];
assert.deepEqual(resolveLineDraft(120, 80, 130, 260, shapes, canvasWidth, canvasHeight), {
startX: 60,
startY: 80,
endX: 260,
endY: 80,
orientation: 'horizontal',
});
});
test('ignores crossing lines that are behind the start point or outside the crossing range', () => {
const shapes: DrawShapeLike[] = [
line({ x1: 60, y1: 20, x2: 60, y2: 180, orientation: 'vertical' }),
line({ x1: 320, y1: 120, x2: 320, y2: 220, orientation: 'vertical' }),
line({ x1: 20, y1: 20, x2: 260, y2: 20, orientation: 'horizontal' }),
rect(),
];
assert.deepEqual(resolveLineDraft(120, 80, 480, 90, shapes, canvasWidth, canvasHeight), {
startX: 120,
startY: 20,
endX: 120,
endY: canvasHeight,
orientation: 'vertical',
});
assert.deepEqual(resolveLineDraft(120, 80, 130, 260, shapes, canvasWidth, canvasHeight), {
startX: 60,
startY: 80,
endX: canvasWidth,
endY: 80,
orientation: 'horizontal',
});
});
test('does not snap to a crossing line that starts exactly at the same point', () => {
const shapes: DrawShapeLike[] = [
line({ x1: 120, y1: 40, x2: 120, y2: 160, orientation: 'vertical' }),
line({ x1: 120, y1: 180, x2: 240, y2: 180, orientation: 'horizontal' }),
];
assert.deepEqual(resolveLineDraft(120, 80, 300, 90, shapes, canvasWidth, canvasHeight), {
startX: 120,
startY: 0,
endX: 120,
endY: 180,
orientation: 'vertical',
});
assert.deepEqual(resolveLineDraft(120, 180, 130, 260, shapes, canvasWidth, canvasHeight), {
startX: 0,
startY: 180,
endX: canvasWidth,
endY: 180,
orientation: 'horizontal',
});
});
test('extends to the nearest crossing line on both forward and reverse directions', () => {
const shapes: DrawShapeLike[] = [
line({ x1: 60, y1: 20, x2: 60, y2: 180, orientation: 'vertical' }),
line({ x1: 260, y1: 20, x2: 260, y2: 180, orientation: 'vertical' }),
line({ x1: 20, y1: 40, x2: 220, y2: 40, orientation: 'horizontal' }),
line({ x1: 20, y1: 180, x2: 220, y2: 180, orientation: 'horizontal' }),
];
assert.deepEqual(resolveLineDraft(120, 80, 320, 90, shapes, canvasWidth, canvasHeight), {
startX: 120,
startY: 40,
endX: 120,
endY: 180,
orientation: 'vertical',
});
assert.deepEqual(resolveLineDraft(120, 100, 130, 260, shapes, canvasWidth, canvasHeight), {
startX: 60,
startY: 100,
endX: 260,
endY: 100,
orientation: 'horizontal',
});
});
test('respects preferred orientation when the pointer drift would otherwise flip the axis', () => {
assert.deepEqual(resolveLineDraft(120, 80, 140, 160, [], canvasWidth, canvasHeight, 'horizontal'), {
startX: 0,
startY: 80,
endX: canvasWidth,
endY: 80,
orientation: 'horizontal',
});
assert.deepEqual(resolveLineDraft(120, 80, 260, 100, [], canvasWidth, canvasHeight, 'vertical'), {
startX: 120,
startY: 0,
endX: 120,
endY: canvasHeight,
orientation: 'vertical',
});
});