feat: add play apps and layout tools
This commit is contained in:
1046
src/features/layout/draw/LayoutDrawPage.css
Normal file
1046
src/features/layout/draw/LayoutDrawPage.css
Normal file
File diff suppressed because it is too large
Load Diff
3516
src/features/layout/draw/LayoutDrawPage.tsx
Normal file
3516
src/features/layout/draw/LayoutDrawPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
src/features/layout/draw/index.ts
Normal file
1
src/features/layout/draw/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LayoutDrawPage } from './LayoutDrawPage';
|
||||
246
src/features/layout/draw/layoutDrawComponentStorage.ts
Normal file
246
src/features/layout/draw/layoutDrawComponentStorage.ts
Normal 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 }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
68
src/features/layout/draw/layoutDrawHistory.ts
Normal file
68
src/features/layout/draw/layoutDrawHistory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
237
src/features/layout/draw/layoutDrawRegions.ts
Normal file
237
src/features/layout/draw/layoutDrawRegions.ts
Normal 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;
|
||||
}
|
||||
99
src/features/layout/draw/layoutDrawSelectionUtils.ts
Normal file
99
src/features/layout/draw/layoutDrawSelectionUtils.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
32
src/features/layout/draw/layoutDrawShapeUtils.ts
Normal file
32
src/features/layout/draw/layoutDrawShapeUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
244
src/features/layout/draw/layoutDrawStorage.ts
Normal file
244
src/features/layout/draw/layoutDrawStorage.ts
Normal 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 }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
24
src/features/layout/draw/layoutDrawStorageShapes.ts
Normal file
24
src/features/layout/draw/layoutDrawStorageShapes.ts
Normal 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 : []);
|
||||
}
|
||||
62
src/features/layout/draw/layoutDrawTypes.ts
Normal file
62
src/features/layout/draw/layoutDrawTypes.ts
Normal 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[];
|
||||
};
|
||||
107
src/features/layout/draw/lineDraft.ts
Normal file
107
src/features/layout/draw/lineDraft.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -135,7 +135,7 @@
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-nav {
|
||||
@@ -251,7 +251,7 @@
|
||||
.feature-menu-layout-page__editor-shell {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-self: stretch;
|
||||
height: calc(100% - 24px);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__field:first-of-type {
|
||||
@@ -290,14 +290,14 @@
|
||||
|
||||
.feature-menu-layout-page__textarea.ant-input {
|
||||
align-self: stretch;
|
||||
height: calc(100% - 4px) !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__notes {
|
||||
height: calc(100% - 4px);
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
padding: 7px 12px 7px;
|
||||
padding-bottom: 7px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess';
|
||||
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
|
||||
import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel';
|
||||
import { buildChatPath } from '../../../app/main/routes';
|
||||
import { useTokenAccess } from '../../../app/main/tokenAccess';
|
||||
@@ -251,6 +252,7 @@ export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsCh
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
modalRender: renderModalWithEnterConfirm,
|
||||
async onOk() {
|
||||
const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id);
|
||||
const nextTree =
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
|
||||
import { confirmWithKeyboard } from '../../app/main/modalKeyboard';
|
||||
import {
|
||||
buildAutomationTypeOptions,
|
||||
resolveAutomationTypeLabel,
|
||||
@@ -646,6 +647,7 @@ export function PlanBoardPage({
|
||||
const appConfig = useAppConfig();
|
||||
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const [items, setItems] = useState<PlanItem[]>([]);
|
||||
const [reviewIndicatorsByPlanId, setReviewIndicatorsByPlanId] = useState<Record<number, ReviewListIndicator>>({});
|
||||
@@ -1415,7 +1417,14 @@ export function PlanBoardPage({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('선택한 작업 메모를 삭제할까요?')) {
|
||||
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||
title: '선택한 작업 메모를 삭제할까요?',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1748,6 +1757,7 @@ export function PlanBoardPage({
|
||||
return (
|
||||
<div className="plan-board-page">
|
||||
{contextHolder}
|
||||
{modalContextHolder}
|
||||
|
||||
{isMobileAutomationLayout ? null : (
|
||||
<Card className="plan-board-page__overview" bordered={false}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
Modal,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from '../../app/main/automationContextAccess';
|
||||
import { buildPlansPath } from '../../app/main/routes';
|
||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||
import { confirmWithKeyboard } from '../../app/main/modalKeyboard';
|
||||
import './planBoard.css';
|
||||
import './planSchedule.css';
|
||||
import { maskNotePreviewByWord } from './noteMasking';
|
||||
@@ -596,6 +598,7 @@ export function PlanSchedulePage() {
|
||||
const { automationTypes } = useAutomationTypeRegistry();
|
||||
const { automationContexts } = useAutomationContextRegistry();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const [items, setItems] = useState<PlanScheduledTask[]>([]);
|
||||
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -701,7 +704,14 @@ export function PlanSchedulePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('선택한 스케줄을 삭제할까요?')) {
|
||||
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||
title: '선택한 스케줄을 삭제할까요?',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -752,6 +762,7 @@ export function PlanSchedulePage() {
|
||||
return (
|
||||
<div className="plan-schedule-page">
|
||||
{contextHolder}
|
||||
{modalContextHolder}
|
||||
<Card className="plan-schedule-page__overview" bordered={false}>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<div>
|
||||
|
||||
217
src/views/play/apps/apps/AppsLibraryView.css
Normal file
217
src/views/play/apps/apps/AppsLibraryView.css
Normal 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;
|
||||
}
|
||||
}
|
||||
133
src/views/play/apps/apps/AppsLibraryView.tsx
Normal file
133
src/views/play/apps/apps/AppsLibraryView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/views/play/apps/apps/appsRegistry.tsx
Normal file
114
src/views/play/apps/apps/appsRegistry.tsx
Normal 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;
|
||||
}
|
||||
1228
src/views/play/apps/e-reader/EReaderAppView.css
Normal file
1228
src/views/play/apps/e-reader/EReaderAppView.css
Normal file
File diff suppressed because it is too large
Load Diff
3201
src/views/play/apps/e-reader/EReaderAppView.tsx
Normal file
3201
src/views/play/apps/e-reader/EReaderAppView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
308
src/views/play/apps/e-reader/eReaderApi.ts
Normal file
308
src/views/play/apps/e-reader/eReaderApi.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
277
src/views/play/apps/photo-puzzle/PhotoPuzzleAppView.css
Normal file
277
src/views/play/apps/photo-puzzle/PhotoPuzzleAppView.css
Normal 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;
|
||||
}
|
||||
}
|
||||
652
src/views/play/apps/photo-puzzle/PhotoPuzzleAppView.tsx
Normal file
652
src/views/play/apps/photo-puzzle/PhotoPuzzleAppView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
800
src/views/play/apps/photo-puzzle/photoPuzzleAppView.css
Normal file
800
src/views/play/apps/photo-puzzle/photoPuzzleAppView.css
Normal 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;
|
||||
}
|
||||
}
|
||||
315
src/views/play/apps/photo-puzzle/photoPuzzleCatalog.ts
Normal file
315
src/views/play/apps/photo-puzzle/photoPuzzleCatalog.ts
Normal 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: '글래머',
|
||||
};
|
||||
413
src/views/play/apps/photo-puzzle/photoPuzzleLibrary.ts
Normal file
413
src/views/play/apps/photo-puzzle/photoPuzzleLibrary.ts
Normal 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',
|
||||
};
|
||||
3236
src/views/play/apps/photoprism/PhotoPrismAppView.css
Normal file
3236
src/views/play/apps/photoprism/PhotoPrismAppView.css
Normal file
File diff suppressed because it is too large
Load Diff
4907
src/views/play/apps/photoprism/PhotoPrismAppView.tsx
Normal file
4907
src/views/play/apps/photoprism/PhotoPrismAppView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
359
src/views/play/apps/photoprism/photoPrismApi.ts
Normal file
359
src/views/play/apps/photoprism/photoPrismApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,613 @@
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 32px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
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%);
|
||||
color: var(--test-play-text);
|
||||
}
|
||||
|
||||
.test-play-app__hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
.test-play-app__filters,
|
||||
.test-play-app__grid-panel {
|
||||
min-height: 0;
|
||||
border: 1px solid var(--test-play-border);
|
||||
border-radius: 14px;
|
||||
background: var(--test-play-surface);
|
||||
box-shadow: 0 12px 28px var(--test-play-shadow);
|
||||
}
|
||||
|
||||
.test-play-app__hero-copy,
|
||||
.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 {
|
||||
.test-play-app__filters {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
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;
|
||||
color: #f5f6f8;
|
||||
}
|
||||
|
||||
.test-play-app__spotlight .ant-typography,
|
||||
.test-play-app__spotlight .ant-typography-copy,
|
||||
.test-play-app__spotlight .ant-typography-secondary {
|
||||
color: inherit;
|
||||
.test-play-app__filters-toggle.ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.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%;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.test-play-app__feature-label {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #7d5b1f;
|
||||
.test-play-app__grid-surface .ag-root-wrapper {
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.test-play-app {
|
||||
min-height: 100dvh;
|
||||
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
height: 100%;
|
||||
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);
|
||||
}
|
||||
|
||||
.test-play-app__hero-copy {
|
||||
padding: 24px;
|
||||
.test-play-app__grid-toolbar {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.test-play-app__spotlight .ant-card-body {
|
||||
min-height: 180px;
|
||||
.test-play-app__grid-meta-inline {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
import 'ag-grid-community/styles/ag-grid.css';
|
||||
import 'ag-grid-community/styles/ag-theme-quartz.css';
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
title: 'Isolated Entry',
|
||||
description: '기존 Layout Editor 상태와 분리된 전용 play 앱 진입점입니다.',
|
||||
},
|
||||
{
|
||||
title: 'Scoped Styling',
|
||||
description: '이 화면은 `TestPlayAppView.css`만 사용하도록 분리해 독립 스타일 실험이 가능합니다.',
|
||||
},
|
||||
{
|
||||
title: 'Next Extension',
|
||||
description: '이후 라우트, 상태, API 연결을 현재 앱과 분리된 구조로 계속 확장할 수 있습니다.',
|
||||
},
|
||||
];
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const STATUS_OPTIONS = ['전체', '접수', '배정완료', '조치중', '부품대기', '완료'];
|
||||
const PRIORITY_OPTIONS = ['전체', '긴급', '높음', '보통', '낮음'];
|
||||
const LINE_OPTIONS = ['전체', 'PKG', 'MFG', 'UTL', 'QC'];
|
||||
const EDITABLE_PRIORITY_OPTIONS: TestAppMaintenanceRequestRow['priority'][] = ['긴급', '높음', '보통', '낮음'];
|
||||
const EDITABLE_STATUS_OPTIONS: TestAppMaintenanceRequestRow['status'][] = ['접수', '배정완료', '조치중', '부품대기', '완료'];
|
||||
const EDITABLE_FIELDS = ['priority', 'status', 'assigneeName'] as const;
|
||||
|
||||
type MaintenancePriority = TestAppMaintenanceRequestRow['priority'];
|
||||
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() {
|
||||
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 (
|
||||
<>
|
||||
{contextHolder}
|
||||
<div className="test-play-app">
|
||||
<section className="test-play-app__hero">
|
||||
<div className="test-play-app__hero-copy">
|
||||
<Tag color="gold">Apps / Test</Tag>
|
||||
<Title level={2}>분리된 test 앱 작업 공간</Title>
|
||||
<Paragraph>
|
||||
Play 사이드바의 <Text strong>Apps</Text> 카테고리에서 진입하는 전용 앱 뷰입니다. 기존 layout editor와
|
||||
경로, 렌더링, 스타일 파일을 분리해 새 앱 형태를 바로 실험할 수 있게 구성했습니다.
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button type="primary">Primary Action</Button>
|
||||
<Button ghost>Secondary</Button>
|
||||
</Space>
|
||||
<section className="test-play-app__filters">
|
||||
<div className="test-play-app__heading">
|
||||
<strong>설비 점검 요청 목록</strong>
|
||||
</div>
|
||||
|
||||
<Card className="test-play-app__spotlight" bordered={false}>
|
||||
<Text type="secondary">test app shell</Text>
|
||||
<Title level={4}>별도 index CSS 대신 전용 뷰 CSS 분리</Title>
|
||||
<Paragraph>
|
||||
React 엔트리는 공유하되, 실제 화면 스타일은 이 앱 전용 CSS로 한정해 기존 앱 전역 규칙과 충돌을 줄였습니다.
|
||||
</Paragraph>
|
||||
<Input placeholder="다음 단계에서 앱 전용 입력/상태를 붙일 수 있습니다." />
|
||||
</Card>
|
||||
<div className="test-play-app__filters-main">
|
||||
<Input
|
||||
className="test-play-app__search-input"
|
||||
value={draftFilters.keyword}
|
||||
placeholder="설비명 · 작업번호 · 요청자 검색"
|
||||
onChange={(event) => {
|
||||
setDraftFilters((previous) => ({
|
||||
...previous,
|
||||
keyword: event.target.value,
|
||||
}));
|
||||
}}
|
||||
aria-label="검색어"
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
|
||||
<div className="test-play-app__icon-actions">
|
||||
<Tooltip title="조회">
|
||||
<Button className="test-play-app__icon-button test-play-app__icon-button--primary" aria-label="조회" icon={<SearchOutlined />} type="primary" onClick={handleSearch} />
|
||||
</Tooltip>
|
||||
<Tooltip title={isExpanded ? '추가 필터 접기' : '추가 필터 펼치기'}>
|
||||
<Button
|
||||
type="default"
|
||||
className="test-play-app__icon-button test-play-app__filters-toggle"
|
||||
aria-label={isExpanded ? '추가 필터 접기' : '추가 필터 펼치기'}
|
||||
aria-expanded={isExpanded}
|
||||
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>
|
||||
|
||||
<Row gutter={[20, 20]}>
|
||||
{featureCards.map((card) => (
|
||||
<Col key={card.title} xs={24} md={8}>
|
||||
<Card className="test-play-app__feature-card" bordered={false}>
|
||||
<Text className="test-play-app__feature-label">{card.title}</Text>
|
||||
<Paragraph>{card.description}</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
260
src/views/play/apps/test/testPlayAppApi.ts
Normal file
260
src/views/play/apps/test/testPlayAppApi.ts
Normal 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 === '대기' ? '' : '호환저장',
|
||||
})),
|
||||
}),
|
||||
});
|
||||
}
|
||||
1213
src/views/play/apps/tetris/TetrisAppView.css
Normal file
1213
src/views/play/apps/tetris/TetrisAppView.css
Normal file
File diff suppressed because it is too large
Load Diff
1299
src/views/play/apps/tetris/TetrisAppView.tsx
Normal file
1299
src/views/play/apps/tetris/TetrisAppView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
4140
src/views/play/apps/the-quest/TheQuestAppView.css
Normal file
4140
src/views/play/apps/the-quest/TheQuestAppView.css
Normal file
File diff suppressed because it is too large
Load Diff
2385
src/views/play/apps/the-quest/TheQuestAppView.tsx
Normal file
2385
src/views/play/apps/the-quest/TheQuestAppView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2090
src/views/play/apps/the-quest/game/createTheQuestGame.ts
Normal file
2090
src/views/play/apps/the-quest/game/createTheQuestGame.ts
Normal file
File diff suppressed because it is too large
Load Diff
683
src/views/play/apps/the-quest/game/theQuestData.ts
Normal file
683
src/views/play/apps/the-quest/game/theQuestData.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
1495
src/views/play/apps/the-quest/game/theQuestStore.ts
Normal file
1495
src/views/play/apps/the-quest/game/theQuestStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
56
tests/layoutDraw/layoutDrawHistory.test.ts
Normal file
56
tests/layoutDraw/layoutDrawHistory.test.ts
Normal 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);
|
||||
});
|
||||
65
tests/layoutDraw/layoutDrawRegions.test.ts
Normal file
65
tests/layoutDraw/layoutDrawRegions.test.ts
Normal 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');
|
||||
});
|
||||
45
tests/layoutDraw/layoutDrawSelectionUtils.test.ts
Normal file
45
tests/layoutDraw/layoutDrawSelectionUtils.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
86
tests/layoutDraw/layoutDrawShapeUtils.test.ts
Normal file
86
tests/layoutDraw/layoutDrawShapeUtils.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
46
tests/layoutDraw/layoutDrawStorage.test.ts
Normal file
46
tests/layoutDraw/layoutDrawStorage.test.ts
Normal 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'), []);
|
||||
});
|
||||
171
tests/layoutDraw/lineDraft.test.ts
Normal file
171
tests/layoutDraw/lineDraft.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user