feat(monaco,lucide): lazy load large resource

This commit is contained in:
Kyush 2026-03-26 04:40:20 +09:00
commit 9f0ff8f6c3
13 changed files with 194 additions and 51 deletions

View file

@ -53,6 +53,8 @@ pnpm test # 서버 테스트 실행
pnpm run bench # 벤치마크 실행
```
개발 시 관리자 UI는 `http://localhost:3002/dashboard` 로 접속하고, 운영에서는 `http://<host>:3000/dashboard` 를 사용한다.
## Environment Variables
| Variable | Default | Description |

View file

@ -1,16 +1,25 @@
import { Router, Route } from '@solidjs/router';
import { Show } from 'solid-js';
import { Dashboard } from './routes/Dashboard';
import { Users } from './routes/Users';
import { Backends } from './routes/Backends';
import { Permissions } from './routes/Permissions';
import { Analytics } from './routes/Analytics';
import { DetailLogs } from './routes/DetailLogs';
import { Scripts } from './routes/Scripts';
import { lazy, Show, Suspense } from 'solid-js';
import { AuthProvider, useAuth } from './auth';
import { LoginGate } from './components/LoginGate';
import { Panel } from './ui';
const Dashboard = lazy(() => import('./routes/Dashboard').then((module) => ({ default: module.Dashboard })));
const Users = lazy(() => import('./routes/Users').then((module) => ({ default: module.Users })));
const Backends = lazy(() => import('./routes/Backends').then((module) => ({ default: module.Backends })));
const Permissions = lazy(() => import('./routes/Permissions').then((module) => ({ default: module.Permissions })));
const Analytics = lazy(() => import('./routes/Analytics').then((module) => ({ default: module.Analytics })));
const DetailLogs = lazy(() => import('./routes/DetailLogs').then((module) => ({ default: module.DetailLogs })));
const Scripts = lazy(() => import('./routes/Scripts').then((module) => ({ default: module.Scripts })));
function RouteLoadingFallback() {
return (
<div class="auth-screen">
<Panel class="auth-screen__panel" title="Loading Admin Page" description="Preparing the selected dashboard view." />
</div>
);
}
function AuthenticatedApp() {
const auth = useAuth();
@ -24,15 +33,17 @@ function AuthenticatedApp() {
}
>
<Show when={auth.session()?.authenticated} fallback={<LoginGate />}>
<Router base="/dashboard">
<Route path="/" component={Dashboard} />
<Route path="/users" component={Users} />
<Route path="/backends" component={Backends} />
<Route path="/permissions" component={Permissions} />
<Route path="/analytics" component={Analytics} />
<Route path="/detail-logs" component={DetailLogs} />
<Route path="/scripts" component={Scripts} />
</Router>
<Suspense fallback={<RouteLoadingFallback />}>
<Router base="/dashboard">
<Route path="/" component={Dashboard} />
<Route path="/users" component={Users} />
<Route path="/backends" component={Backends} />
<Route path="/permissions" component={Permissions} />
<Route path="/analytics" component={Analytics} />
<Route path="/detail-logs" component={DetailLogs} />
<Route path="/scripts" component={Scripts} />
</Router>
</Suspense>
</Show>
</Show>
);

View file

@ -1,7 +1,8 @@
import { MonacoEditor } from 'solid-monaco';
import { createSignal, onCleanup, onMount } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { createSignal, lazy, onCleanup, onMount, Suspense } from 'solid-js';
const THEME_STORAGE_KEY = 'kyush-theme';
const MonacoEditor = lazy(() => import('solid-monaco').then((module) => ({ default: module.MonacoEditor })));
interface ScriptEditorProps {
value: string;
@ -85,22 +86,36 @@ export async function onResponse(ctx) {
return (
<div class="script-editor">
<MonacoEditor
language="typescript"
value={props.value || defaultCode}
path={props.path}
onChange={(value) => props.onChange(value)}
theme={editorTheme()}
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
automaticLayout: true,
readOnly: props.readonly,
scrollBeyondLastLine: false,
padding: { top: 16, bottom: 16 },
}}
/>
<Suspense
fallback={
<div class="script-editor__loading" role="status" aria-live="polite">
<div class="script-editor__skeleton script-editor__skeleton--toolbar" />
<div class="script-editor__skeleton script-editor__skeleton--line" />
<div class="script-editor__skeleton script-editor__skeleton--line script-editor__skeleton--short" />
<div class="script-editor__skeleton script-editor__skeleton--line" />
<div class="script-editor__skeleton script-editor__skeleton--line script-editor__skeleton--medium" />
<p class="script-editor__loading-copy">Loading editor runtime...</p>
</div>
}
>
<Dynamic
component={MonacoEditor}
language="typescript"
value={props.value || defaultCode}
path={props.path}
onChange={(value: string) => props.onChange(value)}
theme={editorTheme()}
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
automaticLayout: true,
readOnly: props.readonly,
scrollBeyondLastLine: false,
padding: { top: 16, bottom: 16 },
}}
/>
</Suspense>
</div>
);
}

View file

@ -1,5 +1,7 @@
import { createResource, createSignal, Show, type Component } from 'solid-js';
import { Pencil, Plus, Trash2 } from 'lucide-solid';
import Pencil from 'lucide-solid/icons/pencil';
import Plus from 'lucide-solid/icons/plus';
import Trash2 from 'lucide-solid/icons/trash-2';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import type { Backend } from '../types';

View file

@ -1,5 +1,5 @@
import { createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
import { RefreshCcw } from 'lucide-solid';
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import type { RequestLog } from '../types';

View file

@ -1,5 +1,6 @@
import { createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
import { Plus, ShieldMinus } from 'lucide-solid';
import Plus from 'lucide-solid/icons/plus';
import ShieldMinus from 'lucide-solid/icons/shield-minus';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import {

View file

@ -1,8 +1,14 @@
import { createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
import { createMemo, createResource, createSignal, lazy, Show, Suspense, type Component } from 'solid-js';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import { ScriptEditor } from '../components/ScriptEditor';
import { Play, Plus, Power, PowerOff, RefreshCw, RotateCcw, Save, Trash2 } from 'lucide-solid';
import Play from 'lucide-solid/icons/play';
import Plus from 'lucide-solid/icons/plus';
import Power from 'lucide-solid/icons/power';
import PowerOff from 'lucide-solid/icons/power-off';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import Save from 'lucide-solid/icons/save';
import Trash2 from 'lucide-solid/icons/trash-2';
import type { ScriptType, UserScript } from '../types';
import {
Alert,
@ -25,6 +31,7 @@ import {
} from '../ui';
type NoticeTone = 'success' | 'warning' | 'danger' | 'info';
const ScriptEditor = lazy(() => import('../components/ScriptEditor').then((module) => ({ default: module.ScriptEditor })));
interface ScriptFormState {
id?: number;
@ -471,11 +478,13 @@ export const Scripts: Component = () => {
<Tabs.Trigger value="test">Test</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="editor">
<ScriptEditor
value={form().script_code}
path={form().id ? `inmemory://model/scripts/${form().id}.ts` : 'inmemory://model/scripts/draft.ts'}
onChange={(value) => setForm((current) => ({ ...current, script_code: value }))}
/>
<Suspense fallback={<Panel title="Loading Editor" description="Preparing the Monaco runtime for this script." class="script-editor__fallback-panel" />}>
<ScriptEditor
value={form().script_code}
path={form().id ? `inmemory://model/scripts/${form().id}.ts` : 'inmemory://model/scripts/draft.ts'}
onChange={(value) => setForm((current) => ({ ...current, script_code: value }))}
/>
</Suspense>
</Tabs.Content>
<Tabs.Content value="test">
<div class="ui-stack">

View file

@ -1,5 +1,10 @@
import { createMemo, createResource, createSignal, For, Show, type Component } from 'solid-js';
import { Copy, KeyRound, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-solid';
import Copy from 'lucide-solid/icons/copy';
import Ellipsis from 'lucide-solid/icons/ellipsis';
import KeyRound from 'lucide-solid/icons/key-round';
import Pencil from 'lucide-solid/icons/pencil';
import Plus from 'lucide-solid/icons/plus';
import Trash2 from 'lucide-solid/icons/trash-2';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import type { User } from '../types';
@ -262,7 +267,7 @@ export const Users: Component = () => {
<DropdownMenu.Root>
<DropdownMenu.Trigger as={Button} class="ui-button--icon" aria-label="More actions">
<span class="ui-button__icon" aria-hidden="true">
<MoreHorizontal />
<Ellipsis />
</span>
<span class="ui-button__label">More</span>
</DropdownMenu.Trigger>

View file

@ -1,5 +1,14 @@
import { A, useLocation } from '@solidjs/router';
import { ChartColumn, FileCode, LayoutDashboard, LogOut, Logs, Moon, Server, ShieldCheck, Sun, Users } from 'lucide-solid';
import ChartColumn from 'lucide-solid/icons/chart-column';
import FileCode from 'lucide-solid/icons/file-code';
import LayoutDashboard from 'lucide-solid/icons/layout-dashboard';
import LogOut from 'lucide-solid/icons/log-out';
import Logs from 'lucide-solid/icons/logs';
import Moon from 'lucide-solid/icons/moon';
import Server from 'lucide-solid/icons/server';
import ShieldCheck from 'lucide-solid/icons/shield-check';
import Sun from 'lucide-solid/icons/sun';
import Users from 'lucide-solid/icons/users';
import { For, createMemo, createSignal, onCleanup, onMount, type JSX, type ParentComponent } from 'solid-js';
import SnakegroundBg from '../../components/SnakegroundBg';
import { useAuth } from '../../auth';

View file

@ -1,5 +1,7 @@
.script-editor {
position: relative;
display: flex;
align-items: stretch;
min-width: 0;
min-height: 600px;
overflow: hidden;
@ -21,6 +23,56 @@
max-width: 100% !important;
}
.script-editor__loading {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-3);
justify-content: center;
padding: var(--space-5);
}
.script-editor__loading-copy {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-1);
}
.script-editor__skeleton {
border-radius: var(--radius-2);
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--color-bg-overlay) 92%, transparent) 0%,
color-mix(in srgb, var(--color-bg-surface) 92%, transparent) 50%,
color-mix(in srgb, var(--color-bg-overlay) 92%, transparent) 100%
);
background-size: 220% 100%;
animation: script-editor-shimmer 1.4s ease-in-out infinite;
}
.script-editor__skeleton--toolbar {
height: 40px;
width: 38%;
}
.script-editor__skeleton--line {
height: 18px;
width: 100%;
}
.script-editor__skeleton--medium {
width: 72%;
}
.script-editor__skeleton--short {
width: 54%;
}
.script-editor__fallback-panel {
min-height: 600px;
}
.script-target {
min-width: 0;
}
@ -68,6 +120,10 @@
.script-editor > * {
height: 520px !important;
}
.script-editor__fallback-panel {
min-height: 520px;
}
}
@media (max-width: 640px) {
@ -78,4 +134,18 @@
.script-editor > * {
height: 420px !important;
}
.script-editor__fallback-panel {
min-height: 420px;
}
}
@keyframes script-editor-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}

View file

@ -3,7 +3,24 @@ import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
base: '/dashboard/',
plugins: [solidPlugin()],
plugins: [
solidPlugin(),
{
name: 'dashboard-trailing-slash-redirect',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === '/dashboard') {
res.statusCode = 302;
res.setHeader('Location', '/dashboard/');
res.end();
return;
}
next();
});
},
},
],
server: {
port: 3002,
proxy: {

View file

@ -48,11 +48,13 @@ client/src/
## Dev And Production
- 개발 서버 포트: `3002`
- 개발 브라우저 진입 경로: `http://localhost:3002/dashboard`
- 개발 API 프록시: `/admin` -> `http://localhost:3000`
- 운영에서는 Express 서버가 빌드된 `client/dist`를 직접 서빙한다
- 운영 브라우저 진입 경로: `http://<host>:3000/dashboard`
SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `/admin/**`로 호출한다.
관리자 라우트는 route-level lazy loading을 사용하고, `Scripts` 화면의 Monaco editor는 editor 영역에서만 지연 로딩된다.
## Admin Auth Notes

View file

@ -62,7 +62,7 @@ async function main() {
log(colors.green, '===============================================');
log(colors.green, 'Development servers started:');
log(colors.green, ` - Express API Server: http://localhost:3000`);
log(colors.green, ` - Vite Admin Dashboard: http://localhost:3001`);
log(colors.green, ` - Vite Admin Dashboard: http://localhost:3002/dashboard`);
log(colors.green, '===============================================');
log(colors.yellow, 'Press Ctrl+C to stop all servers');