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;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-menu-layout-page__tabs .ant-tabs-nav {
|
.feature-menu-layout-page__tabs .ant-tabs-nav {
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
.feature-menu-layout-page__editor-shell {
|
.feature-menu-layout-page__editor-shell {
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
height: calc(100% - 24px);
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-menu-layout-page__field:first-of-type {
|
.feature-menu-layout-page__field:first-of-type {
|
||||||
@@ -290,14 +290,14 @@
|
|||||||
|
|
||||||
.feature-menu-layout-page__textarea.ant-input {
|
.feature-menu-layout-page__textarea.ant-input {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
height: calc(100% - 4px) !important;
|
height: 100% !important;
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-menu-layout-page__notes {
|
.feature-menu-layout-page__notes {
|
||||||
height: calc(100% - 4px);
|
height: 100%;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
padding: 7px 12px 7px;
|
padding: 7px 12px 7px;
|
||||||
padding-bottom: 7px;
|
padding-bottom: 7px;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess';
|
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess';
|
||||||
|
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
|
||||||
import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel';
|
import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel';
|
||||||
import { buildChatPath } from '../../../app/main/routes';
|
import { buildChatPath } from '../../../app/main/routes';
|
||||||
import { useTokenAccess } from '../../../app/main/tokenAccess';
|
import { useTokenAccess } from '../../../app/main/tokenAccess';
|
||||||
@@ -251,6 +252,7 @@ export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsCh
|
|||||||
okText: '삭제',
|
okText: '삭제',
|
||||||
cancelText: '취소',
|
cancelText: '취소',
|
||||||
okButtonProps: { danger: true },
|
okButtonProps: { danger: true },
|
||||||
|
modalRender: renderModalWithEnterConfirm,
|
||||||
async onOk() {
|
async onOk() {
|
||||||
const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id);
|
const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id);
|
||||||
const nextTree =
|
const nextTree =
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
|
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
|
||||||
|
import { confirmWithKeyboard } from '../../app/main/modalKeyboard';
|
||||||
import {
|
import {
|
||||||
buildAutomationTypeOptions,
|
buildAutomationTypeOptions,
|
||||||
resolveAutomationTypeLabel,
|
resolveAutomationTypeLabel,
|
||||||
@@ -646,6 +647,7 @@ export function PlanBoardPage({
|
|||||||
const appConfig = useAppConfig();
|
const appConfig = useAppConfig();
|
||||||
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
|
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const [items, setItems] = useState<PlanItem[]>([]);
|
const [items, setItems] = useState<PlanItem[]>([]);
|
||||||
const [reviewIndicatorsByPlanId, setReviewIndicatorsByPlanId] = useState<Record<number, ReviewListIndicator>>({});
|
const [reviewIndicatorsByPlanId, setReviewIndicatorsByPlanId] = useState<Record<number, ReviewListIndicator>>({});
|
||||||
@@ -1415,7 +1417,14 @@ export function PlanBoardPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.confirm('선택한 작업 메모를 삭제할까요?')) {
|
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||||
|
title: '선택한 작업 메모를 삭제할까요?',
|
||||||
|
okText: '삭제',
|
||||||
|
cancelText: '취소',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1748,6 +1757,7 @@ export function PlanBoardPage({
|
|||||||
return (
|
return (
|
||||||
<div className="plan-board-page">
|
<div className="plan-board-page">
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
{modalContextHolder}
|
||||||
|
|
||||||
{isMobileAutomationLayout ? null : (
|
{isMobileAutomationLayout ? null : (
|
||||||
<Card className="plan-board-page__overview" bordered={false}>
|
<Card className="plan-board-page__overview" bordered={false}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
|
Modal,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
} from '../../app/main/automationContextAccess';
|
} from '../../app/main/automationContextAccess';
|
||||||
import { buildPlansPath } from '../../app/main/routes';
|
import { buildPlansPath } from '../../app/main/routes';
|
||||||
import { useTokenAccess } from '../../app/main/tokenAccess';
|
import { useTokenAccess } from '../../app/main/tokenAccess';
|
||||||
|
import { confirmWithKeyboard } from '../../app/main/modalKeyboard';
|
||||||
import './planBoard.css';
|
import './planBoard.css';
|
||||||
import './planSchedule.css';
|
import './planSchedule.css';
|
||||||
import { maskNotePreviewByWord } from './noteMasking';
|
import { maskNotePreviewByWord } from './noteMasking';
|
||||||
@@ -596,6 +598,7 @@ export function PlanSchedulePage() {
|
|||||||
const { automationTypes } = useAutomationTypeRegistry();
|
const { automationTypes } = useAutomationTypeRegistry();
|
||||||
const { automationContexts } = useAutomationContextRegistry();
|
const { automationContexts } = useAutomationContextRegistry();
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||||
const [items, setItems] = useState<PlanScheduledTask[]>([]);
|
const [items, setItems] = useState<PlanScheduledTask[]>([]);
|
||||||
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
|
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
@@ -701,7 +704,14 @@ export function PlanSchedulePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.confirm('선택한 스케줄을 삭제할까요?')) {
|
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||||
|
title: '선택한 스케줄을 삭제할까요?',
|
||||||
|
okText: '삭제',
|
||||||
|
cancelText: '취소',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,6 +762,7 @@ export function PlanSchedulePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="plan-schedule-page">
|
<div className="plan-schedule-page">
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
|
{modalContextHolder}
|
||||||
<Card className="plan-schedule-page__overview" bordered={false}>
|
<Card className="plan-schedule-page__overview" bordered={false}>
|
||||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
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-app {
|
||||||
|
--test-play-text: #2f3a47;
|
||||||
|
--test-play-text-strong: #202b37;
|
||||||
|
--test-play-text-muted: #697586;
|
||||||
|
--test-play-border: #e2e5ea;
|
||||||
|
--test-play-border-strong: #d2d8e0;
|
||||||
|
--test-play-surface: rgba(255, 255, 255, 0.92);
|
||||||
|
--test-play-surface-strong: rgba(252, 252, 253, 0.97);
|
||||||
|
--test-play-shadow: rgba(77, 88, 102, 0.1);
|
||||||
|
--test-play-accent: #1677ff;
|
||||||
|
--test-play-accent-strong: #0958d9;
|
||||||
|
--test-play-accent-soft: #e8f3ff;
|
||||||
|
--test-play-danger: #c97e89;
|
||||||
|
--test-play-danger-strong: #b46572;
|
||||||
|
--test-play-danger-soft: #fff0f2;
|
||||||
|
--test-play-warning: #c7ab67;
|
||||||
|
--test-play-warning-strong: #aa8740;
|
||||||
|
--test-play-warning-soft: #f8f1de;
|
||||||
|
--test-play-active-soft: #eef4ff;
|
||||||
|
--test-play-header-start: #fbfcfd;
|
||||||
|
--test-play-header-end: #f1f4f8;
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 0;
|
||||||
overflow: auto;
|
max-height: 100%;
|
||||||
overscroll-behavior: contain;
|
overflow: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
padding: 8px;
|
||||||
padding: 32px;
|
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(255, 211, 105, 0.24), transparent 28%),
|
radial-gradient(circle at top left, rgba(255, 211, 105, 0.18), transparent 28%),
|
||||||
linear-gradient(160deg, #f4efe2 0%, #fcfaf4 48%, #eef3f8 100%);
|
linear-gradient(160deg, #f4efe2 0%, #fcfaf4 48%, #eef3f8 100%);
|
||||||
|
color: var(--test-play-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-play-app__hero {
|
.test-play-app__filters,
|
||||||
display: grid;
|
.test-play-app__grid-panel {
|
||||||
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
|
min-height: 0;
|
||||||
align-items: start;
|
border: 1px solid var(--test-play-border);
|
||||||
gap: 24px;
|
border-radius: 14px;
|
||||||
margin-bottom: 24px;
|
background: var(--test-play-surface);
|
||||||
|
box-shadow: 0 12px 28px var(--test-play-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-play-app__hero-copy,
|
.test-play-app__filters {
|
||||||
.test-play-app__spotlight,
|
|
||||||
.test-play-app__feature-card {
|
|
||||||
border-radius: 28px;
|
|
||||||
border: 1px solid rgba(34, 49, 63, 0.08);
|
|
||||||
box-shadow: 0 22px 60px rgba(63, 79, 92, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-play-app__hero-copy {
|
|
||||||
padding: 32px;
|
|
||||||
background: rgba(255, 252, 244, 0.82);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-play-app__hero-copy .ant-typography h2 {
|
|
||||||
margin-top: 14px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
font-size: clamp(2rem, 3vw, 3.2rem);
|
|
||||||
line-height: 1.04;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-play-app__hero-copy .ant-typography {
|
|
||||||
max-width: 640px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-play-app__spotlight {
|
|
||||||
background: linear-gradient(180deg, #1e2b31 0%, #263b45 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-play-app__spotlight .ant-card-body {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__heading strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--test-play-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__filters-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__search-input .ant-input {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app :where(.ant-input, .ant-select-selector) {
|
||||||
|
border-color: var(--test-play-border-strong) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.98) !important;
|
||||||
|
color: var(--test-play-text) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app :where(.ant-input:hover, .ant-select:hover .ant-select-selector) {
|
||||||
|
border-color: #b7c0cb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app :where(.ant-input:focus, .ant-input-focused, .ant-select-focused .ant-select-selector) {
|
||||||
|
border-color: var(--test-play-accent) !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__icon-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__icon-button.ant-btn {
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
border-color: #d6dbe3;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #3f4b59;
|
||||||
|
box-shadow: 0 6px 14px rgba(77, 88, 102, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__icon-button.ant-btn:hover,
|
||||||
|
.test-play-app__icon-button.ant-btn:focus-visible {
|
||||||
|
border-color: #8cb8ff;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(22, 119, 255, 0.14),
|
||||||
|
0 8px 18px rgba(77, 88, 102, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__icon-button.ant-btn.ant-btn-primary,
|
||||||
|
.test-play-app__icon-button--primary.ant-btn {
|
||||||
|
border-color: var(--test-play-accent);
|
||||||
|
background: var(--test-play-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 8px 18px rgba(22, 119, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__icon-button.ant-btn.ant-btn-primary:hover,
|
||||||
|
.test-play-app__icon-button.ant-btn.ant-btn-primary:focus-visible,
|
||||||
|
.test-play-app__icon-button--primary.ant-btn:hover,
|
||||||
|
.test-play-app__icon-button--primary.ant-btn:focus-visible {
|
||||||
|
border-color: var(--test-play-accent-strong);
|
||||||
|
background: var(--test-play-accent-strong);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(22, 119, 255, 0.18),
|
||||||
|
0 10px 22px rgba(22, 119, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__icon-button--danger.ant-btn {
|
||||||
|
border-color: #e3b7c3;
|
||||||
|
background: linear-gradient(180deg, #fff9fb 0%, #fbe8ee 100%);
|
||||||
|
color: var(--test-play-danger-strong);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 18px rgba(201, 126, 137, 0.14),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__icon-button--danger.ant-btn:hover,
|
||||||
|
.test-play-app__icon-button--danger.ant-btn:focus-visible {
|
||||||
|
border-color: var(--test-play-danger);
|
||||||
|
background: linear-gradient(180deg, #fff7fa 0%, #f7dfe8 100%);
|
||||||
|
color: var(--test-play-danger-strong);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(201, 134, 156, 0.18),
|
||||||
|
0 10px 20px rgba(201, 126, 137, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__icon-button.ant-btn:disabled,
|
||||||
|
.test-play-app__icon-button.ant-btn.ant-btn-disabled,
|
||||||
|
.test-play-app__icon-button.ant-btn[disabled] {
|
||||||
|
border-color: #e2e6ec !important;
|
||||||
|
background: #f6f7f9 !important;
|
||||||
|
color: #9aa3af !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__filters-detail {
|
||||||
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
color: #f5f6f8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-play-app__spotlight .ant-typography,
|
.test-play-app__filters-toggle.ant-btn {
|
||||||
.test-play-app__spotlight .ant-typography-copy,
|
flex: 0 0 auto;
|
||||||
.test-play-app__spotlight .ant-typography-secondary {
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-play-app__feature-card {
|
.test-play-app__filters-extra-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: grid-template-rows 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__filters-detail--expanded .test-play-app__filters-extra-shell {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__filters-extra {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
transition:
|
||||||
|
opacity 0.22s ease,
|
||||||
|
transform 0.28s ease,
|
||||||
|
padding-bottom 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__filters-detail--expanded .test-play-app__filters-extra {
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #eceff3;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__filter-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__filter-field > span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--test-play-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e8ebf0;
|
||||||
|
background: linear-gradient(180deg, #fcfcfd 0%, #f5f7fa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-meta-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--test-play-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-meta-inline-range,
|
||||||
|
.test-play-app__grid-meta-inline-page {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-meta-inline-divider {
|
||||||
|
color: #aab3be;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-surface {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-surface .ant-spin-nested-loading,
|
||||||
|
.test-play-app__grid-surface .ant-spin-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(255, 255, 255, 0.84);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-play-app__feature-label {
|
.test-play-app__grid-surface .ag-root-wrapper {
|
||||||
display: inline-block;
|
height: 100%;
|
||||||
margin-bottom: 8px;
|
border: 0;
|
||||||
font-size: 0.78rem;
|
border-radius: 0;
|
||||||
letter-spacing: 0.08em;
|
}
|
||||||
text-transform: uppercase;
|
|
||||||
color: #7d5b1f;
|
.test-play-app__grid-surface .ag-header {
|
||||||
|
border-bottom: 1px solid #dfe5ec;
|
||||||
|
background: linear-gradient(180deg, var(--test-play-header-start) 0%, var(--test-play-header-end) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-surface .ag-header-cell,
|
||||||
|
.test-play-app__grid-surface .ag-header-group-cell {
|
||||||
|
color: #4a5563;
|
||||||
|
font-weight: 700;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: inset -1px 0 0 rgba(202, 208, 217, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-surface .ag-row {
|
||||||
|
transition: background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--odd {
|
||||||
|
--test-play-row-bg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--even {
|
||||||
|
--test-play-row-bg: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--dirty {
|
||||||
|
--test-play-row-bg: var(--test-play-warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--active {
|
||||||
|
--test-play-row-bg: var(--test-play-active-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-cell.ag-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--test-play-row-bg, #fff);
|
||||||
|
color: var(--test-play-text);
|
||||||
|
box-shadow: inset 0 -1px 0 rgba(226, 231, 238, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-cell--edited.ag-cell {
|
||||||
|
color: #80551d;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(180deg, #fbf3dd 0%, #f6ebcf 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 3px 0 0 var(--test-play-warning),
|
||||||
|
inset 0 -1px 0 rgba(214, 176, 109, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-cell--active.ag-cell {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(22, 119, 255, 0.2),
|
||||||
|
inset 0 -1px 0 rgba(226, 231, 238, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-shell {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-shell--open {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(44, 63, 79, 0);
|
||||||
|
transition: background-color 0.28s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-shell--open .test-play-app__editor-backdrop {
|
||||||
|
background: rgba(56, 78, 97, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-panel {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(255, 211, 105, 0.12), transparent 24%),
|
||||||
|
linear-gradient(160deg, #f8f5ec 0%, #fcfbf7 48%, #f1f4f8 100%);
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
box-shadow: -16px 0 44px rgba(86, 110, 125, 0.16);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-shell--open .test-play-app__editor-panel {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: max(18px, env(safe-area-inset-top, 0px)) 24px 18px;
|
||||||
|
border-bottom: 1px solid rgba(225, 229, 235, 0.92);
|
||||||
|
background: rgba(252, 251, 248, 0.94);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-heading strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--test-play-text-strong);
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-heading span {
|
||||||
|
color: var(--test-play-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid rgba(220, 225, 232, 0.92);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 8px 18px rgba(102, 112, 122, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-actions .ant-tooltip-disabled-compatible-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-close.ant-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-delete.ant-btn,
|
||||||
|
.test-play-app__editor-close.ant-btn,
|
||||||
|
.test-play-app__editor-apply.ant-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-card,
|
||||||
|
.test-play-app__editor-form {
|
||||||
|
border: 1px solid rgba(223, 227, 233, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 18px 36px rgba(102, 112, 122, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-card-label {
|
||||||
|
color: var(--test-play-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-card strong {
|
||||||
|
color: var(--test-play-text-strong);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-form {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-field > span {
|
||||||
|
color: #5e7287;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-field .ant-input,
|
||||||
|
.test-play-app__editor-field .ant-select-selector {
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-field--edited .ant-input,
|
||||||
|
.test-play-app__editor-field--edited .ant-select-selector {
|
||||||
|
border-color: var(--test-play-warning) !important;
|
||||||
|
background: #faf5e8 !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(214, 176, 109, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-note {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px dashed #d9dde4;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(180deg, #fffdf8 0%, #f6f2e8 100%);
|
||||||
|
color: #6a7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-note strong {
|
||||||
|
color: #404956;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.test-play-app__filters-extra {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.test-play-app {
|
.test-play-app {
|
||||||
min-height: 100dvh;
|
height: 100%;
|
||||||
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
|
min-height: 0;
|
||||||
|
padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-play-app__hero {
|
.test-play-app__filters-extra {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-toolbar {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-actions {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-header,
|
||||||
|
.test-play-app__editor-body,
|
||||||
|
.test-play-app__editor-form {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-form-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.test-play-app__filters-main {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__filters-extra {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-play-app__hero-copy {
|
.test-play-app__grid-toolbar {
|
||||||
padding: 24px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-play-app__spotlight .ant-card-body {
|
.test-play-app__grid-meta-inline {
|
||||||
min-height: 180px;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__grid-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-heading strong {
|
||||||
|
font-size: 20px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-heading span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-play-app__editor-summary {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,678 @@
|
|||||||
import { Button, Card, Col, Input, Row, Space, Tag, Typography } from 'antd';
|
import { CheckOutlined, CloseOutlined, DeleteOutlined, DownOutlined, ReloadOutlined, SaveOutlined, SearchOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Input, Popconfirm, Select, Spin, Tooltip, message } from 'antd';
|
||||||
|
import type { BodyScrollEndEvent, ColDef, GridReadyEvent, RowDoubleClickedEvent, ValueFormatterParams } from 'ag-grid-community';
|
||||||
|
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
|
||||||
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
deleteTestAppMaintenanceRequest,
|
||||||
|
fetchTestAppMaintenanceRequests,
|
||||||
|
saveTestAppMaintenanceRequests,
|
||||||
|
type TestAppMaintenanceRequestFilters,
|
||||||
|
type TestAppMaintenanceRequestRow,
|
||||||
|
} from './testPlayAppApi';
|
||||||
import './TestPlayAppView.css';
|
import './TestPlayAppView.css';
|
||||||
|
|
||||||
const { Paragraph, Text, Title } = Typography;
|
import 'ag-grid-community/styles/ag-grid.css';
|
||||||
|
import 'ag-grid-community/styles/ag-theme-quartz.css';
|
||||||
|
|
||||||
const featureCards = [
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
{
|
|
||||||
title: 'Isolated Entry',
|
const PAGE_SIZE = 100;
|
||||||
description: '기존 Layout Editor 상태와 분리된 전용 play 앱 진입점입니다.',
|
const STATUS_OPTIONS = ['전체', '접수', '배정완료', '조치중', '부품대기', '완료'];
|
||||||
},
|
const PRIORITY_OPTIONS = ['전체', '긴급', '높음', '보통', '낮음'];
|
||||||
{
|
const LINE_OPTIONS = ['전체', 'PKG', 'MFG', 'UTL', 'QC'];
|
||||||
title: 'Scoped Styling',
|
const EDITABLE_PRIORITY_OPTIONS: TestAppMaintenanceRequestRow['priority'][] = ['긴급', '높음', '보통', '낮음'];
|
||||||
description: '이 화면은 `TestPlayAppView.css`만 사용하도록 분리해 독립 스타일 실험이 가능합니다.',
|
const EDITABLE_STATUS_OPTIONS: TestAppMaintenanceRequestRow['status'][] = ['접수', '배정완료', '조치중', '부품대기', '완료'];
|
||||||
},
|
const EDITABLE_FIELDS = ['priority', 'status', 'assigneeName'] as const;
|
||||||
{
|
|
||||||
title: 'Next Extension',
|
type MaintenancePriority = TestAppMaintenanceRequestRow['priority'];
|
||||||
description: '이후 라우트, 상태, API 연결을 현재 앱과 분리된 구조로 계속 확장할 수 있습니다.',
|
type MaintenanceFilterState = TestAppMaintenanceRequestFilters;
|
||||||
},
|
type MaintenanceRequestRow = TestAppMaintenanceRequestRow;
|
||||||
];
|
type EditableField = (typeof EDITABLE_FIELDS)[number];
|
||||||
|
type EditedFieldEntry = Partial<Record<EditableField, true>>;
|
||||||
|
type EditedFieldMap = Record<number, EditedFieldEntry>;
|
||||||
|
|
||||||
|
const INITIAL_FILTERS: MaintenanceFilterState = {
|
||||||
|
keyword: '',
|
||||||
|
lineCode: '전체',
|
||||||
|
priority: '전체',
|
||||||
|
status: '전체',
|
||||||
|
requestedFrom: '',
|
||||||
|
requestedTo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function priorityRank(priority: MaintenancePriority) {
|
||||||
|
if (priority === '긴급') {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
if (priority === '높음') {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (priority === '보통') {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(params: ValueFormatterParams<MaintenanceRequestRow>) {
|
||||||
|
return params.value ? String(params.value).replace('T', ' ').slice(0, 16) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEditedFieldEntry(row: MaintenanceRequestRow, baseline?: MaintenanceRequestRow) {
|
||||||
|
const editedEntry: EditedFieldEntry = {};
|
||||||
|
|
||||||
|
if (!baseline) {
|
||||||
|
return editedEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of EDITABLE_FIELDS) {
|
||||||
|
if (row[field] !== baseline[field]) {
|
||||||
|
editedEntry[field] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return editedEntry;
|
||||||
|
}
|
||||||
|
|
||||||
export function TestPlayAppView() {
|
export function TestPlayAppView() {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const gridApiRef = useRef<GridReadyEvent<MaintenanceRequestRow>['api'] | null>(null);
|
||||||
|
const baselineRowsRef = useRef<Record<number, MaintenanceRequestRow>>({});
|
||||||
|
const [filters, setFilters] = useState<MaintenanceFilterState>(INITIAL_FILTERS);
|
||||||
|
const [draftFilters, setDraftFilters] = useState<MaintenanceFilterState>(INITIAL_FILTERS);
|
||||||
|
const [rows, setRows] = useState<MaintenanceRequestRow[]>([]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [hasNext, setHasNext] = useState(false);
|
||||||
|
const [editedFieldMap, setEditedFieldMap] = useState<EditedFieldMap>({});
|
||||||
|
const [editingRowId, setEditingRowId] = useState<number | null>(null);
|
||||||
|
const loadingNextPageRef = useRef(false);
|
||||||
|
|
||||||
|
const editingRow = editingRowId === null ? null : rows.find((row) => row.id === editingRowId) ?? null;
|
||||||
|
const loadedEnd = rows.length;
|
||||||
|
const loadedRangeLabel = loadedEnd > 0 ? `1-${loadedEnd.toLocaleString()} / ${total.toLocaleString()}` : `0 / ${total.toLocaleString()}`;
|
||||||
|
const pageLabel = `${page}p`;
|
||||||
|
|
||||||
|
const updateRowDraft = (rowId: number, patch: Partial<Pick<MaintenanceRequestRow, EditableField>>) => {
|
||||||
|
const currentRow = rows.find((row) => row.id === rowId);
|
||||||
|
|
||||||
|
if (!currentRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRow = {
|
||||||
|
...currentRow,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
const editedEntry = resolveEditedFieldEntry(nextRow, baselineRowsRef.current[rowId]);
|
||||||
|
const isDirty = Object.keys(editedEntry).length > 0;
|
||||||
|
|
||||||
|
setRows((previousRows) =>
|
||||||
|
previousRows.map((row) =>
|
||||||
|
row.id === rowId
|
||||||
|
? {
|
||||||
|
...nextRow,
|
||||||
|
isDirty,
|
||||||
|
}
|
||||||
|
: row,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEditedFieldMap((previousMap) => {
|
||||||
|
const nextMap = { ...previousMap };
|
||||||
|
|
||||||
|
if (isDirty) {
|
||||||
|
nextMap[rowId] = editedEntry;
|
||||||
|
} else {
|
||||||
|
delete nextMap[rowId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextMap;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRows = async (nextPage: number, nextFilters: MaintenanceFilterState, append = false) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (!append) {
|
||||||
|
setEditingRowId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchTestAppMaintenanceRequests(nextPage, PAGE_SIZE, nextFilters);
|
||||||
|
const nextRows = response.items.map((item) => ({ ...item, isDirty: false }));
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
for (const row of nextRows) {
|
||||||
|
baselineRowsRef.current[row.id] = { ...row, isDirty: false };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baselineRowsRef.current = Object.fromEntries(nextRows.map((row) => [row.id, { ...row, isDirty: false }]));
|
||||||
|
setEditedFieldMap({});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRows((previousRows) => (append ? [...previousRows, ...nextRows] : nextRows));
|
||||||
|
setTotal(response.total);
|
||||||
|
setPage(response.page);
|
||||||
|
setHasNext(response.hasNext);
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '점검 요청 데이터를 불러오지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
loadingNextPageRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRows(1, INITIAL_FILTERS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingRowId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editingRow) {
|
||||||
|
setEditingRowId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
const previousHtmlOverflow = html.style.overflow;
|
||||||
|
const previousBodyOverflow = body.style.overflow;
|
||||||
|
const previousBodyPaddingRight = body.style.paddingRight;
|
||||||
|
const scrollbarWidth = Math.max(0, window.innerWidth - html.clientWidth);
|
||||||
|
|
||||||
|
html.style.overflow = 'hidden';
|
||||||
|
body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
body.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
html.style.overflow = previousHtmlOverflow;
|
||||||
|
body.style.overflow = previousBodyOverflow;
|
||||||
|
body.style.paddingRight = previousBodyPaddingRight;
|
||||||
|
};
|
||||||
|
}, [editingRow, editingRowId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingRowId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setEditingRowId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [editingRowId]);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const nextFilters = {
|
||||||
|
...draftFilters,
|
||||||
|
keyword: draftFilters.keyword.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setFilters(nextFilters);
|
||||||
|
void loadRows(1, nextFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setDraftFilters(INITIAL_FILTERS);
|
||||||
|
setFilters(INITIAL_FILTERS);
|
||||||
|
void loadRows(1, INITIAL_FILTERS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const dirtyRows = rows.filter((row) => row.isDirty);
|
||||||
|
|
||||||
|
if (!dirtyRows.length) {
|
||||||
|
messageApi.info('저장할 변경 행이 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveTestAppMaintenanceRequests(dirtyRows);
|
||||||
|
await loadRows(1, filters);
|
||||||
|
messageApi.success('저장을 완료했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!editingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTestAppMaintenanceRequest(editingRow.id);
|
||||||
|
setEditingRowId(null);
|
||||||
|
await loadRows(1, filters);
|
||||||
|
messageApi.success('요청을 삭제했습니다.');
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBodyScrollEnd = (event: BodyScrollEndEvent<MaintenanceRequestRow>) => {
|
||||||
|
if (event.direction !== 'vertical' || loadingNextPageRef.current || isLoading || !hasNext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = gridApiRef.current;
|
||||||
|
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = api.getVerticalPixelRange();
|
||||||
|
const container = document.querySelector('.test-play-app__grid-surface .ag-body-viewport') as HTMLElement | null;
|
||||||
|
const remaining = container ? container.scrollHeight - range.bottom : Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
if (remaining > 80) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingNextPageRef.current = true;
|
||||||
|
void loadRows(page + 1, filters, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnDefs: ColDef<MaintenanceRequestRow>[] = [
|
||||||
|
{ field: 'requestNo', headerName: '작업번호', minWidth: 132, maxWidth: 148, pinned: 'left' },
|
||||||
|
{ field: 'lineCode', headerName: '라인', minWidth: 110, maxWidth: 124 },
|
||||||
|
{ field: 'equipmentName', headerName: '설비명', minWidth: 190, flex: 1.2 },
|
||||||
|
{ field: 'issueType', headerName: '이상유형', minWidth: 144, flex: 1 },
|
||||||
|
{
|
||||||
|
field: 'priority',
|
||||||
|
headerName: '우선순위',
|
||||||
|
minWidth: 108,
|
||||||
|
maxWidth: 118,
|
||||||
|
comparator: (left: MaintenancePriority, right: MaintenancePriority) => priorityRank(left) - priorityRank(right),
|
||||||
|
},
|
||||||
|
{ field: 'requesterName', headerName: '요청자', minWidth: 112, maxWidth: 128 },
|
||||||
|
{ field: 'assigneeName', headerName: '담당자', minWidth: 112, maxWidth: 128 },
|
||||||
|
{ field: 'status', headerName: '상태', minWidth: 120, maxWidth: 132 },
|
||||||
|
{ field: 'requestedAt', headerName: '요청시각', minWidth: 168, valueFormatter: formatDateTime },
|
||||||
|
{ field: 'lastActionAt', headerName: '최근조치시각', minWidth: 176, valueFormatter: formatDateTime },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const column of columnDefs) {
|
||||||
|
column.cellClass = (params) => {
|
||||||
|
const classNames = ['test-play-app__grid-cell'];
|
||||||
|
const rowId = params.data?.id ?? null;
|
||||||
|
const fieldName = params.colDef.field as EditableField | undefined;
|
||||||
|
|
||||||
|
if (rowId !== null && fieldName && editedFieldMap[rowId]?.[fieldName]) {
|
||||||
|
classNames.push('test-play-app__grid-cell--edited');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowId !== null && editingRowId === rowId) {
|
||||||
|
classNames.push('test-play-app__grid-cell--active');
|
||||||
|
}
|
||||||
|
|
||||||
|
return classNames;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="test-play-app">
|
<>
|
||||||
<section className="test-play-app__hero">
|
{contextHolder}
|
||||||
<div className="test-play-app__hero-copy">
|
<div className="test-play-app">
|
||||||
<Tag color="gold">Apps / Test</Tag>
|
<section className="test-play-app__filters">
|
||||||
<Title level={2}>분리된 test 앱 작업 공간</Title>
|
<div className="test-play-app__heading">
|
||||||
<Paragraph>
|
<strong>설비 점검 요청 목록</strong>
|
||||||
Play 사이드바의 <Text strong>Apps</Text> 카테고리에서 진입하는 전용 앱 뷰입니다. 기존 layout editor와
|
</div>
|
||||||
경로, 렌더링, 스타일 파일을 분리해 새 앱 형태를 바로 실험할 수 있게 구성했습니다.
|
|
||||||
</Paragraph>
|
|
||||||
<Space wrap>
|
|
||||||
<Button type="primary">Primary Action</Button>
|
|
||||||
<Button ghost>Secondary</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="test-play-app__spotlight" bordered={false}>
|
<div className="test-play-app__filters-main">
|
||||||
<Text type="secondary">test app shell</Text>
|
<Input
|
||||||
<Title level={4}>별도 index CSS 대신 전용 뷰 CSS 분리</Title>
|
className="test-play-app__search-input"
|
||||||
<Paragraph>
|
value={draftFilters.keyword}
|
||||||
React 엔트리는 공유하되, 실제 화면 스타일은 이 앱 전용 CSS로 한정해 기존 앱 전역 규칙과 충돌을 줄였습니다.
|
placeholder="설비명 · 작업번호 · 요청자 검색"
|
||||||
</Paragraph>
|
onChange={(event) => {
|
||||||
<Input placeholder="다음 단계에서 앱 전용 입력/상태를 붙일 수 있습니다." />
|
setDraftFilters((previous) => ({
|
||||||
</Card>
|
...previous,
|
||||||
</section>
|
keyword: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
aria-label="검색어"
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
/>
|
||||||
|
|
||||||
<Row gutter={[20, 20]}>
|
<div className="test-play-app__icon-actions">
|
||||||
{featureCards.map((card) => (
|
<Tooltip title="조회">
|
||||||
<Col key={card.title} xs={24} md={8}>
|
<Button className="test-play-app__icon-button test-play-app__icon-button--primary" aria-label="조회" icon={<SearchOutlined />} type="primary" onClick={handleSearch} />
|
||||||
<Card className="test-play-app__feature-card" bordered={false}>
|
</Tooltip>
|
||||||
<Text className="test-play-app__feature-label">{card.title}</Text>
|
<Tooltip title={isExpanded ? '추가 필터 접기' : '추가 필터 펼치기'}>
|
||||||
<Paragraph>{card.description}</Paragraph>
|
<Button
|
||||||
</Card>
|
type="default"
|
||||||
</Col>
|
className="test-play-app__icon-button test-play-app__filters-toggle"
|
||||||
))}
|
aria-label={isExpanded ? '추가 필터 접기' : '추가 필터 펼치기'}
|
||||||
</Row>
|
aria-expanded={isExpanded}
|
||||||
</div>
|
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setIsExpanded((previous) => !previous);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`test-play-app__filters-detail ${isExpanded ? 'test-play-app__filters-detail--expanded' : ''}`}>
|
||||||
|
<div className="test-play-app__filters-extra-shell">
|
||||||
|
<div className="test-play-app__filters-extra">
|
||||||
|
<label className="test-play-app__filter-field">
|
||||||
|
<span>라인</span>
|
||||||
|
<Select
|
||||||
|
value={draftFilters.lineCode}
|
||||||
|
options={LINE_OPTIONS.map((value) => ({ value, label: value }))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setDraftFilters((previous) => ({
|
||||||
|
...previous,
|
||||||
|
lineCode: value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="test-play-app__filter-field">
|
||||||
|
<span>우선순위</span>
|
||||||
|
<Select
|
||||||
|
value={draftFilters.priority}
|
||||||
|
options={PRIORITY_OPTIONS.map((value) => ({ value, label: value }))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setDraftFilters((previous) => ({
|
||||||
|
...previous,
|
||||||
|
priority: value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="test-play-app__filter-field">
|
||||||
|
<span>상태</span>
|
||||||
|
<Select
|
||||||
|
value={draftFilters.status}
|
||||||
|
options={STATUS_OPTIONS.map((value) => ({ value, label: value }))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setDraftFilters((previous) => ({
|
||||||
|
...previous,
|
||||||
|
status: value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="test-play-app__filter-field">
|
||||||
|
<span>요청 시작일</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={draftFilters.requestedFrom}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraftFilters((previous) => ({
|
||||||
|
...previous,
|
||||||
|
requestedFrom: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="test-play-app__filter-field">
|
||||||
|
<span>요청 종료일</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={draftFilters.requestedTo}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraftFilters((previous) => ({
|
||||||
|
...previous,
|
||||||
|
requestedTo: event.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="test-play-app__grid-panel">
|
||||||
|
<div className="test-play-app__grid-toolbar">
|
||||||
|
<div className="test-play-app__grid-meta-inline" aria-label="페이지 정보">
|
||||||
|
<span className="test-play-app__grid-meta-inline-range">{loadedRangeLabel}</span>
|
||||||
|
<span className="test-play-app__grid-meta-inline-divider" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="test-play-app__grid-meta-inline-page">{pageLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="test-play-app__grid-actions">
|
||||||
|
<Tooltip title="검색조건 초기화">
|
||||||
|
<Button className="test-play-app__icon-button" aria-label="검색조건 초기화" icon={<ReloadOutlined />} onClick={handleReset} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="저장">
|
||||||
|
<Button
|
||||||
|
className="test-play-app__icon-button test-play-app__icon-button--primary"
|
||||||
|
aria-label="저장"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
type="primary"
|
||||||
|
loading={isSaving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="test-play-app__grid-surface ag-theme-quartz">
|
||||||
|
<Spin spinning={isLoading}>
|
||||||
|
<AgGridReact<MaintenanceRequestRow>
|
||||||
|
rowData={rows}
|
||||||
|
columnDefs={columnDefs}
|
||||||
|
defaultColDef={{
|
||||||
|
sortable: true,
|
||||||
|
resizable: true,
|
||||||
|
editable: false,
|
||||||
|
filter: false,
|
||||||
|
}}
|
||||||
|
getRowId={(params) => String(params.data.id)}
|
||||||
|
rowHeight={42}
|
||||||
|
headerHeight={42}
|
||||||
|
suppressMovableColumns
|
||||||
|
animateRows={false}
|
||||||
|
onGridReady={(event) => {
|
||||||
|
gridApiRef.current = event.api;
|
||||||
|
event.api.sizeColumnsToFit();
|
||||||
|
}}
|
||||||
|
onGridSizeChanged={() => {
|
||||||
|
gridApiRef.current?.sizeColumnsToFit();
|
||||||
|
}}
|
||||||
|
onBodyScrollEnd={handleBodyScrollEnd}
|
||||||
|
onRowDoubleClicked={(event: RowDoubleClickedEvent<MaintenanceRequestRow>) => {
|
||||||
|
if (!event.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingRowId(event.data.id);
|
||||||
|
}}
|
||||||
|
rowClassRules={{
|
||||||
|
'test-play-app__grid-row--odd': (params) => ((params.node.rowIndex ?? 0) + 1) % 2 === 1,
|
||||||
|
'test-play-app__grid-row--even': (params) => ((params.node.rowIndex ?? 0) + 1) % 2 === 0,
|
||||||
|
'test-play-app__grid-row--dirty': (params) => Boolean(params.data?.isDirty),
|
||||||
|
'test-play-app__grid-row--active': (params) => params.data?.id === editingRowId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`test-play-app__editor-shell ${editingRow ? 'test-play-app__editor-shell--open' : ''}`} aria-hidden={editingRow ? 'false' : 'true'}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="test-play-app__editor-backdrop"
|
||||||
|
aria-label="편집 패널 닫기"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="test-play-app__editor-panel" aria-label="점검 요청 편집 패널">
|
||||||
|
<header className="test-play-app__editor-header">
|
||||||
|
<div className="test-play-app__editor-heading">
|
||||||
|
<strong>{editingRow?.equipmentName ?? '점검 요청 편집'}</strong>
|
||||||
|
<span>{editingRow ? `${editingRow.requestNo} · ${editingRow.lineCode} · ${editingRow.issueType}` : '행 더블클릭으로 편집 패널을 엽니다.'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="test-play-app__editor-actions">
|
||||||
|
<Popconfirm
|
||||||
|
title="현재 요청을 삭제할까요?"
|
||||||
|
description="삭제 후에는 목록에서 제거되고 새로고침 후에도 복구되지 않습니다."
|
||||||
|
okText="삭제"
|
||||||
|
cancelText="취소"
|
||||||
|
okButtonProps={{ danger: true, loading: isDeleting }}
|
||||||
|
onConfirm={() => void handleDelete()}
|
||||||
|
disabled={!editingRow}
|
||||||
|
>
|
||||||
|
<Tooltip title="삭제">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="default"
|
||||||
|
className="test-play-app__icon-button test-play-app__icon-button--danger test-play-app__editor-delete"
|
||||||
|
aria-label="삭제"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
loading={isDeleting}
|
||||||
|
disabled={!editingRow}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
<Tooltip title="적용 후 닫기">
|
||||||
|
<Button
|
||||||
|
className="test-play-app__icon-button test-play-app__icon-button--primary test-play-app__editor-apply"
|
||||||
|
aria-label="적용 후 닫기"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
type="primary"
|
||||||
|
disabled={!editingRow}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="닫기">
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
className="test-play-app__icon-button test-play-app__editor-close"
|
||||||
|
aria-label="닫기"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="test-play-app__editor-body">
|
||||||
|
<section className="test-play-app__editor-summary">
|
||||||
|
<div className="test-play-app__editor-card">
|
||||||
|
<span className="test-play-app__editor-card-label">작업번호</span>
|
||||||
|
<strong>{editingRow?.requestNo ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="test-play-app__editor-card">
|
||||||
|
<span className="test-play-app__editor-card-label">요청자</span>
|
||||||
|
<strong>{editingRow?.requesterName ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="test-play-app__editor-card">
|
||||||
|
<span className="test-play-app__editor-card-label">요청시각</span>
|
||||||
|
<strong>{editingRow?.requestedAt ? editingRow.requestedAt.replace('T', ' ').slice(0, 16) : '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="test-play-app__editor-card">
|
||||||
|
<span className="test-play-app__editor-card-label">최근조치</span>
|
||||||
|
<strong>{editingRow?.lastActionAt ? editingRow.lastActionAt.replace('T', ' ').slice(0, 16) : '-'}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="test-play-app__editor-form">
|
||||||
|
<div className="test-play-app__editor-form-grid">
|
||||||
|
<label className="test-play-app__editor-field">
|
||||||
|
<span>설비명</span>
|
||||||
|
<Input value={editingRow?.equipmentName ?? ''} readOnly />
|
||||||
|
</label>
|
||||||
|
<label className="test-play-app__editor-field">
|
||||||
|
<span>이상유형</span>
|
||||||
|
<Input value={editingRow?.issueType ?? ''} readOnly />
|
||||||
|
</label>
|
||||||
|
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.priority ? 'test-play-app__editor-field--edited' : ''}`}>
|
||||||
|
<span>우선순위</span>
|
||||||
|
<Select
|
||||||
|
value={editingRow?.priority}
|
||||||
|
options={EDITABLE_PRIORITY_OPTIONS.map((value) => ({ value, label: value }))}
|
||||||
|
disabled={!editingRow}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!editingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRowDraft(editingRow.id, { priority: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.status ? 'test-play-app__editor-field--edited' : ''}`}>
|
||||||
|
<span>상태</span>
|
||||||
|
<Select
|
||||||
|
value={editingRow?.status}
|
||||||
|
options={EDITABLE_STATUS_OPTIONS.map((value) => ({ value, label: value }))}
|
||||||
|
disabled={!editingRow}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!editingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRowDraft(editingRow.id, { status: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.assigneeName ? 'test-play-app__editor-field--edited' : ''}`}>
|
||||||
|
<span>담당자</span>
|
||||||
|
<Input
|
||||||
|
value={editingRow?.assigneeName ?? ''}
|
||||||
|
placeholder="담당자를 입력하세요."
|
||||||
|
disabled={!editingRow}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!editingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRowDraft(editingRow.id, { assigneeName: event.target.value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="test-play-app__editor-note">
|
||||||
|
<strong>저장 범위</strong>
|
||||||
|
<span>현재 패널에서는 우선순위, 상태, 담당자만 수정합니다. 변경 사항은 하단 저장 버튼으로 일괄 반영됩니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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