chore: test deploy snapshot
This commit is contained in:
@@ -97,6 +97,12 @@
|
||||
rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.apps-library__card--template1 {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(113, 184, 255, 0.24), rgba(96, 138, 255, 0.16)),
|
||||
rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.apps-library__card--the-quest {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 201, 112, 0.24), rgba(104, 198, 255, 0.14)),
|
||||
|
||||
@@ -8,7 +8,13 @@ import { PhotoPrismAppView } from '../photoprism/PhotoPrismAppView';
|
||||
import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView';
|
||||
import { TheQuestAppView } from '../the-quest/TheQuestAppView';
|
||||
import { TetrisAppView } from '../tetris/TetrisAppView';
|
||||
import { APP_LIBRARY_ENTRIES, findReadyPlayAppEntryById } from './appsRegistry';
|
||||
import { Template1PlayAppView } from '../template1/Template1PlayAppView';
|
||||
import {
|
||||
getCurrentPlayAppEnvironment,
|
||||
getPlayAppEntries,
|
||||
isPlayAppLaunchableInEnvironment,
|
||||
loadPlayAppEntriesFromServer,
|
||||
} from './appsRegistry';
|
||||
import { buildPlayAppPath } from '../../../../app/main/routes';
|
||||
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../../../../app/main/pwa/installManifest';
|
||||
|
||||
@@ -30,6 +36,8 @@ function resolvePlayAppInstallThemeColor(appId: string) {
|
||||
return '#d97706';
|
||||
case 'the-quest':
|
||||
return '#7c3aed';
|
||||
case 'template1':
|
||||
return '#2f8dff';
|
||||
case 'tetris':
|
||||
return '#0f172a';
|
||||
default:
|
||||
@@ -44,12 +52,47 @@ export function AppsLibraryView() {
|
||||
const [isCompactViewport, setIsCompactViewport] = useState(() =>
|
||||
typeof window === 'undefined' ? false : window.matchMedia('(max-width: 768px)').matches,
|
||||
);
|
||||
const [playAppEntries, setPlayAppEntries] = useState(() => getPlayAppEntries());
|
||||
const [isLoadingPlayApps, setIsLoadingPlayApps] = useState(false);
|
||||
const activeAppId = searchParams.get('app');
|
||||
const launchContext = searchParams.get('launchContext') === 'embedded' ? 'embedded' : 'direct';
|
||||
const returnTo = normalizeReturnToPath(searchParams.get('returnTo'));
|
||||
const activeAppEntry = findReadyPlayAppEntryById(activeAppId);
|
||||
const environment = getCurrentPlayAppEnvironment();
|
||||
const activeAppEntry = useMemo(() => {
|
||||
const candidate = playAppEntries.find((entry) => entry.id === activeAppId);
|
||||
if (!candidate || !isPlayAppLaunchableInEnvironment(candidate, environment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const readyCount = useMemo(() => APP_LIBRARY_ENTRIES.filter((entry) => entry.isReady).length, []);
|
||||
return candidate;
|
||||
}, [activeAppId, environment, playAppEntries]);
|
||||
|
||||
const readyCount = useMemo(
|
||||
() => playAppEntries.filter((entry) => isPlayAppLaunchableInEnvironment(entry, environment)).length,
|
||||
[environment, playAppEntries],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isDisposed = false;
|
||||
setIsLoadingPlayApps(true);
|
||||
|
||||
void loadPlayAppEntriesFromServer()
|
||||
.then((entries) => {
|
||||
if (!isDisposed) {
|
||||
setPlayAppEntries(entries);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!isDisposed) {
|
||||
setIsLoadingPlayApps(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -140,25 +183,31 @@ export function AppsLibraryView() {
|
||||
return <TheQuestAppView onBack={closeApp} launchContext={launchContext} />;
|
||||
}
|
||||
|
||||
if (activeAppEntry?.id === 'template1') {
|
||||
return <Template1PlayAppView 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>
|
||||
<span>{playAppEntries.length}개</span>
|
||||
</div>
|
||||
<Tag bordered={false} color="gold">
|
||||
실행 가능 {readyCount}
|
||||
{isLoadingPlayApps ? '실행 가능 로딩중' : `실행 가능 ${readyCount}`}
|
||||
</Tag>
|
||||
</header>
|
||||
|
||||
<div className={`apps-library__shelf${isCompactViewport ? ' apps-library__shelf--compact' : ''}`}>
|
||||
{APP_LIBRARY_ENTRIES.map((entry) => (
|
||||
{playAppEntries.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={`apps-library__card ${entry.accentClassName}${entry.isReady ? ' apps-library__card--ready' : ''}`}
|
||||
disabled={!entry.isReady}
|
||||
className={`apps-library__card ${
|
||||
entry.accentClassName
|
||||
}${isPlayAppLaunchableInEnvironment(entry, environment) ? ' apps-library__card--ready' : ''}`}
|
||||
disabled={!isPlayAppLaunchableInEnvironment(entry, environment)}
|
||||
data-testid={
|
||||
entry.id === 'e-reader'
|
||||
? 'apps-library-open-e-reader'
|
||||
@@ -170,8 +219,10 @@ export function AppsLibraryView() {
|
||||
? 'apps-library-open-photo-puzzle'
|
||||
: entry.id === 'tetris'
|
||||
? 'apps-library-open-tetris'
|
||||
: entry.id === 'the-quest'
|
||||
? 'apps-library-open-the-quest'
|
||||
: entry.id === 'the-quest'
|
||||
? 'apps-library-open-the-quest'
|
||||
: entry.id === 'template1'
|
||||
? 'apps-library-open-template1'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => openApp(entry.id)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AppstoreAddOutlined,
|
||||
BellOutlined,
|
||||
BookOutlined,
|
||||
FileImageOutlined,
|
||||
@@ -15,6 +16,19 @@ import type { ReactNode } from 'react';
|
||||
|
||||
export type PlayAppEnvironment = 'preview' | 'test' | 'prod';
|
||||
|
||||
type PlayAppServerRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
accentClassName: string;
|
||||
statusLabel: string;
|
||||
isReady: boolean;
|
||||
iconName: string;
|
||||
usagePriority?: number;
|
||||
supportedEnvironments?: PlayAppEnvironment[];
|
||||
searchKeywords?: string[];
|
||||
searchDescription?: string;
|
||||
};
|
||||
|
||||
export type PlayAppEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -28,7 +42,25 @@ export type PlayAppEntry = {
|
||||
searchDescription?: string;
|
||||
};
|
||||
|
||||
export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
||||
const PLAY_APP_ICON_REGISTRY: Record<string, ReactNode> = {
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
AppstoreAddOutlined: <AppstoreAddOutlined />,
|
||||
BellOutlined: <BellOutlined />,
|
||||
BookOutlined: <BookOutlined />,
|
||||
FireOutlined: <FireOutlined />,
|
||||
FundProjectionScreenOutlined: <FundProjectionScreenOutlined />,
|
||||
FileImageOutlined: <FileImageOutlined />,
|
||||
PictureOutlined: <PictureOutlined />,
|
||||
RocketOutlined: <RocketOutlined />,
|
||||
SoundOutlined: <SoundOutlined />,
|
||||
StarOutlined: <StarOutlined />,
|
||||
ThunderboltOutlined: <ThunderboltOutlined />,
|
||||
};
|
||||
|
||||
const PLAY_APP_API_PATH = '/api/play-apps';
|
||||
const FALLBACK_ICON = <AppstoreOutlined />;
|
||||
|
||||
const FALLBACK_ENTRIES: PlayAppEntry[] = [
|
||||
{
|
||||
id: 'baseball-ticket-bay',
|
||||
name: '야구-티켓베이',
|
||||
@@ -89,6 +121,18 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
||||
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
|
||||
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
|
||||
},
|
||||
{
|
||||
id: 'template1',
|
||||
name: 'Template1',
|
||||
accentClassName: 'apps-library__card--template1',
|
||||
statusLabel: '템플릿',
|
||||
isReady: true,
|
||||
icon: <AppstoreAddOutlined />,
|
||||
usagePriority: 45,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['template1', 'template', '앱 템플릿', '레이아웃', '기본 UI', 'layout'],
|
||||
searchDescription: '다른 앱 개발 시 공통 레이아웃을 빠르게 적용하기 위한 템플릿 화면입니다.',
|
||||
},
|
||||
{
|
||||
id: 'tetris',
|
||||
name: 'Tetris',
|
||||
@@ -108,8 +152,123 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
|
||||
{ id: 'app-vault', name: 'App Vault', accentClassName: 'apps-library__card--vault', statusLabel: '테마', isReady: false, icon: <AppstoreOutlined /> },
|
||||
];
|
||||
|
||||
export function getReadyPlayAppEntries() {
|
||||
return APP_LIBRARY_ENTRIES.filter((entry) => entry.isReady);
|
||||
export const APP_LIBRARY_ENTRIES = FALLBACK_ENTRIES;
|
||||
|
||||
let playAppCache = FALLBACK_ENTRIES;
|
||||
let loadingPromise: Promise<PlayAppEntry[]> | null = null;
|
||||
|
||||
function isObjectValue(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function parseSearchKeywords(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function isPlayEnvironment(value: unknown): value is PlayAppEnvironment {
|
||||
return value === 'preview' || value === 'test' || value === 'prod';
|
||||
}
|
||||
|
||||
function normalizePlayAppServerPayload(payload: unknown): PlayAppServerRow[] {
|
||||
if (!isObjectValue(payload) || !Array.isArray(payload.items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload.items
|
||||
.map((raw) => {
|
||||
if (!isObjectValue(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
||||
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
||||
const accentClassName = typeof raw.accentClassName === 'string' ? raw.accentClassName.trim() : '';
|
||||
const statusLabel = typeof raw.statusLabel === 'string' ? raw.statusLabel.trim() : '';
|
||||
const iconName = typeof raw.iconName === 'string' ? raw.iconName.trim() : '';
|
||||
|
||||
if (!id || !name || !accentClassName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSupported = Array.isArray(raw.supportedEnvironments) ? raw.supportedEnvironments : [];
|
||||
const supportedEnvironments = rawSupported.filter(isPlayEnvironment);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
accentClassName,
|
||||
statusLabel: statusLabel || '준비',
|
||||
isReady: raw.isReady === true,
|
||||
iconName,
|
||||
usagePriority: typeof raw.usagePriority === 'number' ? raw.usagePriority : undefined,
|
||||
supportedEnvironments,
|
||||
searchKeywords: parseSearchKeywords(raw.searchKeywords),
|
||||
searchDescription: typeof raw.searchDescription === 'string' ? raw.searchDescription.trim() : '',
|
||||
} satisfies PlayAppServerRow;
|
||||
})
|
||||
.filter((item): item is PlayAppServerRow => item !== null);
|
||||
}
|
||||
|
||||
function sortPlayAppEntriesByPriority(entries: PlayAppEntry[]) {
|
||||
return entries.slice().sort((lhs, rhs) => {
|
||||
const lhsPriority = lhs.usagePriority ?? 0;
|
||||
const rhsPriority = rhs.usagePriority ?? 0;
|
||||
|
||||
if (rhsPriority !== lhsPriority) {
|
||||
return rhsPriority - lhsPriority;
|
||||
}
|
||||
|
||||
return lhs.id.localeCompare(rhs.id);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSupportedEnvironments(rawEnvironments: PlayAppEnvironment[] | undefined) {
|
||||
if (!rawEnvironments || rawEnvironments.length === 0) {
|
||||
return ['preview'];
|
||||
}
|
||||
|
||||
return rawEnvironments;
|
||||
}
|
||||
|
||||
function getEntryWithIcon(entry: PlayAppServerRow): PlayAppEntry {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
accentClassName: entry.accentClassName,
|
||||
statusLabel: entry.statusLabel,
|
||||
isReady: entry.isReady,
|
||||
icon: PLAY_APP_ICON_REGISTRY[entry.iconName] ?? FALLBACK_ICON,
|
||||
usagePriority: entry.usagePriority,
|
||||
supportedEnvironments: normalizeSupportedEnvironments(entry.supportedEnvironments),
|
||||
searchKeywords: entry.searchKeywords,
|
||||
searchDescription: entry.searchDescription,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentPlayAppEnvironment(): PlayAppEnvironment {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'prod';
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0';
|
||||
|
||||
if (isLocalHost) {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
if (hostname.includes('preview')) {
|
||||
return 'preview';
|
||||
}
|
||||
|
||||
return 'prod';
|
||||
}
|
||||
|
||||
export function getSupportedPlayAppEnvironments(entry: PlayAppEntry): PlayAppEnvironment[] {
|
||||
@@ -124,10 +283,69 @@ export function isPlayAppSupportedInEnvironment(entry: PlayAppEntry, environment
|
||||
return getSupportedPlayAppEnvironments(entry).includes(environment);
|
||||
}
|
||||
|
||||
export function isPlayAppLaunchableInEnvironment(
|
||||
entry: PlayAppEntry,
|
||||
environment: PlayAppEnvironment = getCurrentPlayAppEnvironment(),
|
||||
) {
|
||||
if (!isPlayAppSupportedInEnvironment(entry, environment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (environment === 'preview') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return entry.isReady;
|
||||
}
|
||||
|
||||
export function getPlayAppEntries() {
|
||||
return playAppCache;
|
||||
}
|
||||
|
||||
export function getReadyPlayAppEntries() {
|
||||
const environment = getCurrentPlayAppEnvironment();
|
||||
return playAppCache.filter((entry) => isPlayAppLaunchableInEnvironment(entry, environment));
|
||||
}
|
||||
|
||||
export function findReadyPlayAppEntryById(appId: string | null | undefined) {
|
||||
if (!appId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getReadyPlayAppEntries().find((entry) => entry.id === appId) ?? null;
|
||||
const entry = playAppCache.find((candidate) => candidate.id === appId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isPlayAppLaunchableInEnvironment(entry) ? entry : null;
|
||||
}
|
||||
|
||||
export function loadPlayAppEntriesFromServer() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.resolve(playAppCache);
|
||||
}
|
||||
|
||||
if (!loadingPromise) {
|
||||
loadingPromise = fetch(PLAY_APP_API_PATH)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`앱 목록 조회 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const normalizedRows = normalizePlayAppServerPayload(payload).map(getEntryWithIcon);
|
||||
playAppCache = sortPlayAppEntriesByPriority(normalizedRows.length ? normalizedRows : FALLBACK_ENTRIES);
|
||||
|
||||
return playAppCache;
|
||||
})
|
||||
.catch(() => {
|
||||
playAppCache = FALLBACK_ENTRIES;
|
||||
return playAppCache;
|
||||
})
|
||||
.finally(() => {
|
||||
loadingPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
@@ -1004,15 +1004,14 @@
|
||||
}
|
||||
|
||||
.e-reader__book-card strong {
|
||||
display: -webkit-box;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 1.42;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: keep-all;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.e-reader__book-card p,
|
||||
|
||||
398
src/views/play/apps/template1/Template1PlayAppView.css
Normal file
398
src/views/play/apps/template1/Template1PlayAppView.css
Normal file
@@ -0,0 +1,398 @@
|
||||
.template1-app {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 14px;
|
||||
border-radius: 22px;
|
||||
color: #0f172a;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f4f7fe 100%);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.template1-app__topbar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
backdrop-filter: blur(6px);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96) 0%, rgba(248, 250, 252, 0.75) 100%);
|
||||
}
|
||||
|
||||
.template1-app__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template1-app__badge {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #0c4a9e;
|
||||
background: #dbeafe;
|
||||
border: 1px solid rgba(37, 99, 235, 0.16);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.template1-app__badge:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.template1-app__badge:hover {
|
||||
box-shadow: 0 1px 8px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.template1-app__badge:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.template1-app__badge .anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.template1-app__brand h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(18px, 2vw, 24px);
|
||||
}
|
||||
|
||||
.template1-app__brand p {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.template1-app__topbar-action {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(37, 99, 235, 0.16);
|
||||
color: #0c4a9e;
|
||||
background: #dbeafe;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.template1-app__topbar-action:hover {
|
||||
box-shadow: 0 1px 8px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.template1-app__topbar-action:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.template1-app__topbar-action .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.template1-app__settings-dropdown {
|
||||
position: absolute;
|
||||
top: 58px;
|
||||
right: 14px;
|
||||
z-index: 30;
|
||||
min-width: 208px;
|
||||
width: min(248px, calc(100% - 28px));
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.template1-app__main {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template1-app__scroll-area {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
padding-right: 2px;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.template1-app__screen-transition {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.template1-app__section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.template1-app__section-head p {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.template1-app__chips-text {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.template1-app__section-description {
|
||||
margin: 4px 0 12px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.template1-app__section-head--detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template1-app__section-head--detail small {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template1-app__home-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.template1-app__home-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
min-height: 96px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.88) 100%);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template1-app__home-item div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.template1-app__home-item strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.template1-app__home-item span,
|
||||
.template1-app__home-item small {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.template1-app__home-item small {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.template1-app__detail-shell {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.template1-app__cards {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.template1-app__settings-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.template1-app__settings-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #dbe4f2;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template1-app__settings-item--danger {
|
||||
border-color: #fecaca;
|
||||
background: linear-gradient(180deg, #fff1f2 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.template1-app__settings-item-content {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.template1-app__settings-item-content strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.template1-app__settings-item-content span {
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.template1-app__settings-item-description--muted {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.template1-app__settings-item-icon--danger {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.template1-app__card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 84px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #dbe4f2;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template1-app__card div strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.template1-app__card div span {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.template1-app__card-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.template1-app__card-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.template1-app__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.template1-app__title-row h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.template1-app__cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.template1-app {
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.template1-app__brand h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.template1-app__brand p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.template1-app__cards,
|
||||
.template1-app__home-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.template1-app__home-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.template1-app__section-head--detail {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.template1-app__card {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.template1-app__card-action {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
434
src/views/play/apps/template1/Template1PlayAppView.tsx
Normal file
434
src/views/play/apps/template1/Template1PlayAppView.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AppstoreAddOutlined,
|
||||
BellOutlined,
|
||||
CheckCircleOutlined,
|
||||
CompassOutlined,
|
||||
LogoutOutlined,
|
||||
CloseOutlined,
|
||||
SettingOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import './Template1PlayAppView.css';
|
||||
|
||||
type Template1PlayAppViewProps = {
|
||||
onBack: () => void;
|
||||
launchContext?: 'direct' | 'embedded';
|
||||
};
|
||||
|
||||
type Template1SectionId = 'home' | 'projects' | 'resources' | 'automation';
|
||||
|
||||
type Template1Section = {
|
||||
id: Template1SectionId;
|
||||
label: string;
|
||||
description: string;
|
||||
chips: string[];
|
||||
};
|
||||
|
||||
type Template1MenuItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
screen?: Template1SectionId;
|
||||
actionLabel?: string;
|
||||
actionType?: 'navigate' | 'exit' | 'local';
|
||||
settingAction?: 'theme' | 'notifications' | 'reset-state';
|
||||
};
|
||||
|
||||
const TEMPLATE1_SECTIONS: Template1Section[] = [
|
||||
{
|
||||
id: 'home',
|
||||
label: '홈',
|
||||
description: '빠른 네비게이션으로 주요 기능으로 이동',
|
||||
chips: ['요약', '최근 작업', '바로가기'],
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
label: '작업',
|
||||
description: '프로젝트 관리나 문맥 전환 진입점',
|
||||
chips: ['레이아웃', '요청', '검수'],
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: '리소스',
|
||||
description: '문서/데이터/관리 화면 모음',
|
||||
chips: ['문서', '리소스', '로그'],
|
||||
},
|
||||
{
|
||||
id: 'automation',
|
||||
label: '자동화',
|
||||
description: '자동화와 상태 확인 흐름',
|
||||
chips: ['플랜', '체크리스트', '실행 이력'],
|
||||
},
|
||||
];
|
||||
|
||||
const TEMPLATE1_MENUS: Record<Template1SectionId, Template1MenuItem[]> = {
|
||||
home: [
|
||||
{
|
||||
title: '오늘 개요',
|
||||
description: '최근 작업 수와 진행 상태를 한 화면에서 확인',
|
||||
icon: <CompassOutlined />,
|
||||
screen: 'home',
|
||||
actionLabel: '열기',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
{
|
||||
title: '요청 보드',
|
||||
description: '채팅/플랜 타입의 빠른 이동',
|
||||
icon: <ThunderboltOutlined />,
|
||||
screen: 'projects',
|
||||
actionLabel: '이동',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
{
|
||||
title: '앱 바코드',
|
||||
description: '새 앱 연결 전에 기본 레이아웃 점검',
|
||||
icon: <AppstoreAddOutlined />,
|
||||
screen: 'projects',
|
||||
actionLabel: '확인',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
title: 'Layout Editor',
|
||||
description: '레이아웃 편집 흐름 컴포넌트',
|
||||
icon: <CompassOutlined />,
|
||||
screen: 'projects',
|
||||
actionLabel: '열기',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
{
|
||||
title: 'Layout Draw',
|
||||
description: '컴포넌트 샘플 배치 화면',
|
||||
icon: <ThunderboltOutlined />,
|
||||
screen: 'projects',
|
||||
actionLabel: '열기',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
{
|
||||
title: '저장된 레이아웃',
|
||||
description: '저장 기록과 재열기 진입점',
|
||||
icon: <CheckCircleOutlined />,
|
||||
screen: 'projects',
|
||||
actionLabel: '열기',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
title: '문서',
|
||||
description: '문서 목록과 상세 진입 구성',
|
||||
icon: <CompassOutlined />,
|
||||
screen: 'resources',
|
||||
actionLabel: '열기',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
{
|
||||
title: '리소스 관리',
|
||||
description: '공유 리소스 목록과 승인 상태',
|
||||
icon: <AppstoreAddOutlined />,
|
||||
screen: 'resources',
|
||||
actionLabel: '열기',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
{
|
||||
title: '활동 로그',
|
||||
description: '최근 변경 이력 요약',
|
||||
icon: <BellOutlined />,
|
||||
screen: 'automation',
|
||||
actionLabel: '열기',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
],
|
||||
automation: [
|
||||
{
|
||||
title: '자동화',
|
||||
description: 'Plan/작업 진행 목록',
|
||||
icon: <AppstoreAddOutlined />,
|
||||
screen: 'automation',
|
||||
actionLabel: '열기',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
{
|
||||
title: '체크리스트',
|
||||
description: '요청 단계 추적',
|
||||
icon: <CheckCircleOutlined />,
|
||||
screen: 'automation',
|
||||
actionLabel: '확인',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
{
|
||||
title: '에러 로그',
|
||||
description: '실패/경고 대응 화면',
|
||||
icon: <CloseOutlined />,
|
||||
screen: 'automation',
|
||||
actionLabel: '확인',
|
||||
actionType: 'navigate',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settingsItems: Template1MenuItem[] = [
|
||||
{
|
||||
title: '테마',
|
||||
description: '배경/카드 스타일 상태를 확인',
|
||||
icon: <AppstoreOutlined />,
|
||||
actionType: 'local',
|
||||
settingAction: 'theme',
|
||||
},
|
||||
{
|
||||
title: '알림',
|
||||
description: '알림 영역 설정 진입점',
|
||||
icon: <BellOutlined />,
|
||||
actionType: 'local',
|
||||
settingAction: 'notifications',
|
||||
},
|
||||
{
|
||||
title: '상태 초기화',
|
||||
description: '현재 화면을 홈 상태로 복원',
|
||||
icon: <CheckCircleOutlined />,
|
||||
actionType: 'local',
|
||||
settingAction: 'reset-state',
|
||||
},
|
||||
{
|
||||
title: '앱 종료',
|
||||
description: '설정을 닫고 앱 화면을 종료합니다.',
|
||||
icon: <LogoutOutlined />,
|
||||
actionType: 'exit',
|
||||
},
|
||||
];
|
||||
|
||||
type Template1HomeScreenProps = {
|
||||
items: Pick<Template1MenuItem, 'title' | 'description' | 'icon' | 'actionLabel'> & {
|
||||
chips: string[];
|
||||
screen: Template1SectionId;
|
||||
}[];
|
||||
onItemSelect: (item: Template1MenuItem) => void;
|
||||
};
|
||||
|
||||
type Template1SectionScreenProps = {
|
||||
title: string;
|
||||
chips: string[];
|
||||
cards: Template1MenuItem[];
|
||||
onItemSelect: (item: Template1MenuItem) => void;
|
||||
};
|
||||
|
||||
function Template1HomeScreen({ items, onItemSelect }: Template1HomeScreenProps) {
|
||||
return (
|
||||
<>
|
||||
<header className="template1-app__section-head">
|
||||
<h2>콘텐츠 목록</h2>
|
||||
</header>
|
||||
<div className="template1-app__home-list" aria-label="콘텐츠 목록">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.title}
|
||||
className="template1-app__home-item"
|
||||
onClick={() => onItemSelect({ ...item, screen: item.screen, actionType: 'navigate' })}
|
||||
>
|
||||
<span className="template1-app__card-icon">{item.icon}</span>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.description}</span>
|
||||
<small>{item.chips.join(' · ')}</small>
|
||||
</div>
|
||||
<span className="template1-app__card-action">{item.actionLabel ?? '열기'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Template1SectionScreen({ title, chips, cards, onItemSelect }: Template1SectionScreenProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="template1-app__chips-text">{chips.join(' · ')}</div>
|
||||
<div className="template1-app__cards" aria-label={title}>
|
||||
<div className="template1-app__title-row">
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{cards.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.title}
|
||||
className="template1-app__card"
|
||||
onClick={() => onItemSelect(item)}
|
||||
>
|
||||
<span className="template1-app__card-icon">{item.icon}</span>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.description}</span>
|
||||
</div>
|
||||
<span className="template1-app__card-action">{item.actionLabel ?? '열기'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Template1SettingsDropdown({
|
||||
onItemSelect,
|
||||
}: {
|
||||
onItemSelect: (item: Template1MenuItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="template1-app__settings-dropdown" role="menu" aria-label="설정 메뉴">
|
||||
<div className="template1-app__settings-list">
|
||||
{settingsItems.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.title}
|
||||
className={`template1-app__settings-item${item.actionType === 'exit' ? ' template1-app__settings-item--danger' : ''}`}
|
||||
onClick={() => onItemSelect(item)}
|
||||
>
|
||||
<span
|
||||
className={`template1-app__card-icon template1-app__settings-item-icon${item.actionType === 'exit' ? ' template1-app__settings-item-icon--danger' : ''}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<div className="template1-app__settings-item-content">
|
||||
<strong>{item.title}</strong>
|
||||
<span
|
||||
className={item.actionType === 'exit' ? 'template1-app__settings-item-description--muted' : undefined}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Template1PlayAppView({ onBack, launchContext = 'direct' }: Template1PlayAppViewProps) {
|
||||
const [activeSection, setActiveSection] = useState<Template1SectionId>('home');
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
void launchContext;
|
||||
|
||||
const activeSectionItem = useMemo(
|
||||
() => TEMPLATE1_SECTIONS.find((item) => item.id === activeSection) ?? TEMPLATE1_SECTIONS[0],
|
||||
[activeSection],
|
||||
);
|
||||
|
||||
const menuItems = TEMPLATE1_MENUS[activeSection];
|
||||
|
||||
const homeMenuItems = useMemo(
|
||||
() =>
|
||||
TEMPLATE1_SECTIONS.filter((item) => item.id !== 'home')
|
||||
.map((item) => ({
|
||||
title: item.label,
|
||||
description: item.description,
|
||||
icon:
|
||||
item.id === 'projects' ? <ThunderboltOutlined /> :
|
||||
item.id === 'resources' ? <AppstoreAddOutlined /> :
|
||||
<BellOutlined />,
|
||||
screen: item.id,
|
||||
actionLabel: '열기',
|
||||
chips: item.chips,
|
||||
actionType: 'navigate',
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const goSection = (section: Template1SectionId) => {
|
||||
setActiveSection(section);
|
||||
setIsSettingsOpen(false);
|
||||
};
|
||||
|
||||
const openItem = (item: Template1MenuItem) => {
|
||||
if (item.actionType === 'exit') {
|
||||
onBack();
|
||||
setIsSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.settingAction === 'reset-state') {
|
||||
goSection('home');
|
||||
setIsSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.actionType === 'local') {
|
||||
setIsSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.screen) {
|
||||
goSection(item.screen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHomeIconClick = () => {
|
||||
goSection('home');
|
||||
};
|
||||
|
||||
const renderActiveScreen = () => {
|
||||
if (activeSection === 'home') {
|
||||
return <Template1HomeScreen items={homeMenuItems} onItemSelect={openItem} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Template1SectionScreen
|
||||
title={activeSectionItem.label}
|
||||
chips={activeSectionItem.chips}
|
||||
cards={menuItems}
|
||||
onItemSelect={openItem}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="template1-app">
|
||||
<header className="template1-app__topbar">
|
||||
<div className="template1-app__brand">
|
||||
<button
|
||||
type="button"
|
||||
className="template1-app__badge"
|
||||
aria-label="Template1 홈으로 이동"
|
||||
onClick={handleHomeIconClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleHomeIconClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppstoreOutlined />
|
||||
</button>
|
||||
<div>
|
||||
<h1>Template1 앱 레이아웃</h1>
|
||||
<p>다음 앱에 바로 적용 가능한 기본 배치 예시</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="template1-app__topbar-action"
|
||||
aria-label="Template1 설정 열기"
|
||||
aria-expanded={isSettingsOpen}
|
||||
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||
title="설정"
|
||||
>
|
||||
<SettingOutlined />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isSettingsOpen ? (
|
||||
<Template1SettingsDropdown onItemSelect={openItem} />
|
||||
) : null}
|
||||
|
||||
<main className="template1-app__main">
|
||||
<div className="template1-app__scroll-area">
|
||||
<div key={`template1-section-${activeSection}-${isSettingsOpen ? 'settings' : 'screen'}`} className="template1-app__screen-transition">
|
||||
{renderActiveScreen()}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user