diff --git a/dockerfiles/Hub/Dockerfile b/dockerfiles/Hub/Dockerfile index 487b857..0f1b8cb 100644 --- a/dockerfiles/Hub/Dockerfile +++ b/dockerfiles/Hub/Dockerfile @@ -29,7 +29,8 @@ ARG NPM_REGISTRY WORKDIR /app -RUN corepack enable && corepack prepare pnpm@10.27.0 --activate +RUN if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi +RUN npm install -g pnpm@10.27.0 RUN if [ -n "${NPM_REGISTRY}" ]; then pnpm config set registry "${NPM_REGISTRY}"; fi # Copy monorepo configuration and lockfile @@ -39,6 +40,7 @@ COPY runtime/hub/frontend/pnpm-workspace.yaml runtime/hub/frontend/pnpm-lock.yam COPY runtime/hub/frontend/packages/shared/package.json ./packages/shared/ COPY runtime/hub/frontend/apps/admin/package.json ./apps/admin/ COPY runtime/hub/frontend/apps/spawn/package.json ./apps/spawn/ +COPY runtime/hub/frontend/apps/home/package.json ./apps/home/ # Install dependencies with frozen lockfile RUN pnpm install --frozen-lockfile @@ -59,13 +61,16 @@ USER root # Frontend: templates and static files RUN mkdir -p /tmp/custom_templates/static/css /tmp/custom_templates/static/js \ /usr/local/share/jupyterhub/static/admin-ui \ - /usr/local/share/jupyterhub/static/spawn-ui + /usr/local/share/jupyterhub/static/spawn-ui \ + /usr/local/share/jupyterhub/static/home-ui COPY --chmod=644 runtime/hub/frontend/templates/ /tmp/custom_templates/ COPY --from=frontend-builder /app/apps/admin/dist/ /usr/local/share/jupyterhub/static/admin-ui/ COPY --from=frontend-builder /app/apps/spawn/dist/ /usr/local/share/jupyterhub/static/spawn-ui/ +COPY --from=frontend-builder /app/apps/home/dist/ /usr/local/share/jupyterhub/static/home-ui/ RUN chown -R 1000:1000 /usr/local/share/jupyterhub/static/admin-ui/ \ - /usr/local/share/jupyterhub/static/spawn-ui/ + /usr/local/share/jupyterhub/static/spawn-ui/ \ + /usr/local/share/jupyterhub/static/home-ui/ # Backend: Python dependencies and core logic RUN pip install --no-cache-dir uv @@ -90,4 +95,5 @@ ENV JUPYTERHUB_TEMPLATE_PATH=/tmp/custom_templates RUN ls -la /tmp/custom_templates && \ ls -la /usr/local/share/jupyterhub/static/admin-ui/ && \ - ls -la /usr/local/share/jupyterhub/static/spawn-ui/ + ls -la /usr/local/share/jupyterhub/static/spawn-ui/ && \ + ls -la /usr/local/share/jupyterhub/static/home-ui/ diff --git a/runtime/hub/core/authenticators/auto_login.py b/runtime/hub/core/authenticators/auto_login.py index 9df688c..b7d8096 100644 --- a/runtime/hub/core/authenticators/auto_login.py +++ b/runtime/hub/core/authenticators/auto_login.py @@ -45,7 +45,7 @@ def get_handlers(self, app): """Override to bypass login page and auto-authenticate.""" class AutoLoginHandler(BaseHandler): - """Handler that automatically authenticates and redirects to spawn.""" + """Handler that automatically authenticates and redirects to home.""" async def get(self): """Auto-authenticate user on GET request.""" @@ -59,7 +59,7 @@ async def get(self): next_url = self.get_argument("next", "") if not next_url: - next_url = getattr(user, "url", None) or url_path_join(self.hub.base_url, "spawn") + next_url = url_path_join(self.hub.base_url, "home") self.log.info(f"Auto-login: user '{username}' authenticated, redirecting to {next_url}") self.redirect(next_url) diff --git a/runtime/hub/frontend/apps/home/eslint.config.js b/runtime/hub/frontend/apps/home/eslint.config.js new file mode 100644 index 0000000..54f8816 --- /dev/null +++ b/runtime/hub/frontend/apps/home/eslint.config.js @@ -0,0 +1,35 @@ +import { dirname } from 'path' +import { fileURLToPath } from 'url' +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export default tseslint.config( + { ignores: ['dist'] }, + { + files: ['**/*.{ts,tsx}'], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + tsconfigRootDir: __dirname, + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/runtime/hub/frontend/apps/home/index.html b/runtime/hub/frontend/apps/home/index.html new file mode 100644 index 0000000..a868d51 --- /dev/null +++ b/runtime/hub/frontend/apps/home/index.html @@ -0,0 +1,12 @@ + + + + + + AUP Learning Cloud - Home + + +
+ + + diff --git a/runtime/hub/frontend/apps/home/package.json b/runtime/hub/frontend/apps/home/package.json new file mode 100644 index 0000000..76f7f07 --- /dev/null +++ b/runtime/hub/frontend/apps/home/package.json @@ -0,0 +1,31 @@ +{ + "name": "@auplc/home", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@auplc/shared": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@eslint/js": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "eslint": "catalog:", + "eslint-plugin-react-hooks": "catalog:", + "eslint-plugin-react-refresh": "catalog:", + "globals": "catalog:", + "typescript": "catalog:", + "typescript-eslint": "catalog:", + "vite": "catalog:" + } +} diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx new file mode 100644 index 0000000..51ac8e0 --- /dev/null +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -0,0 +1,451 @@ +import { useState, useEffect, useCallback } from "react"; +import type { Resource, ResourceGroup } from "@auplc/shared"; +import { getResources } from "@auplc/shared"; + +interface HomeData { + server_active: boolean; + server_url: string; +} + +declare global { + interface Window { + HOME_DATA?: HomeData; + AVAILABLE_RESOURCES?: string[]; + } +} + +const jhdata = window.jhdata ?? { + base_url: "/hub/", + xsrf_token: "", + user: "student", +}; + +const baseUrl = jhdata.base_url ?? "/hub/"; + +const homeData: HomeData = window.HOME_DATA ?? { + server_active: false, + server_url: `${baseUrl.replace(/\/?$/, "/")}user/${jhdata.user ?? "student"}/`, +}; + +function formatResourceSpecs(r: Resource): string { + const req = r.requirements; + const mem = req.memory.replace("Gi", "GB"); + let spec = `${req.cpu} CPU, ${mem}`; + if (req["amd.com/gpu"]) spec += `, ${req["amd.com/gpu"]} GPU`; + if (req["amd.com/npu"]) spec += `, ${req["amd.com/npu"]} NPU`; + return spec; +} + +function getAcceleratorType(r: Resource): "gpu" | "npu" | "cpu" { + if (r.requirements["amd.com/gpu"]) return "gpu"; + if (r.requirements["amd.com/npu"]) return "npu"; + return "cpu"; +} + +function App() { + const [serverActive, setServerActive] = useState(homeData.server_active); + const [stopping, setStopping] = useState(false); + const [announcement, setAnnouncement] = useState(null); + const [groups, setGroups] = useState([]); + const [resourcesLoading, setResourcesLoading] = useState(true); + const [resourcesError, setResourcesError] = useState(null); + const [stopError, setStopError] = useState(null); + + useEffect(() => { + fetch(`${baseUrl}static/announcement.txt`) + .then((resp) => { + if (!resp.ok) throw new Error("Not found"); + return resp.text(); + }) + .then((data) => { + if (data?.trim()) setAnnouncement(data.trim()); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + getResources() + .then((data) => { + const allowedKeys = window.AVAILABLE_RESOURCES ?? []; + const hasFilter = allowedKeys.length > 0; + const allowedSet = new Set(allowedKeys); + + const filtered = hasFilter + ? data.groups + .map((g) => ({ + ...g, + resources: g.resources.filter((r) => allowedSet.has(r.key)), + })) + .filter((g) => g.resources.length > 0) + : data.groups.filter((g) => g.resources.length > 0); + + setGroups(filtered); + }) + .catch((err) => { + setResourcesError( + err instanceof Error ? err.message : "Failed to load resources", + ); + }) + .finally(() => setResourcesLoading(false)); + }, []); + + const handleStop = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.nativeEvent.stopImmediatePropagation(); + if (stopping) return; + setStopping(true); + setStopError(null); + try { + const resp = await fetch( + `${baseUrl}api/users/${jhdata.user}/server`, + { + method: "DELETE", + headers: { "X-XSRFToken": jhdata.xsrf_token ?? "" }, + }, + ); + if (resp.ok || resp.status === 204 || resp.status === 202) { + setServerActive(false); + } else { + setStopError(`Failed to stop server (HTTP ${resp.status})`); + } + } catch { + setStopError("Network error — could not reach the server"); + } finally { + setStopping(false); + } + }, + [stopping], + ); + + const totalResources = groups.reduce( + (sum, g) => sum + g.resources.length, + 0, + ); + + return ( +
+ {/* Hero */} +
+
+

+ Welcome to AUP Learning Cloud +

+

+ Experience next-generation AI acceleration with AMD ROCm. Launch + GPU-powered Jupyter notebooks for deep learning, computer vision, + LLMs, and more. +

+
+
+ + {/* Launch Bar */} +
+
+
+ +
+
+
+ My Server + {serverActive ? ( + + Running + + ) : ( + + Stopped + + )} +
+
+ {stopError ? ( + {stopError} + ) : stopping + ? "Stopping your server\u2026" + : serverActive + ? 'Your server is running \u2014 click "My Server" to open JupyterLab' + : "Choose a resource below and launch your Jupyter environment"} +
+
+ +
+
+ + {/* Quick Start (compact strip) */} +
+
+
+
+
1
+
+

Choose a Resource

+

Pick a pre-configured environment below

+
+
+
+
2
+
+

Configure & Launch

+

Select GPU, set runtime, then launch

+
+
+
+
3
+
+

Start Learning

+

Open notebooks and run experiments

+
+
+
+
+
+ + {/* Available Resources (dynamic from API) */} +
+
+
+

Available Resources

+ + View all options{" "} + + +
+ + {resourcesLoading ? ( +
+ Loading resources… +
+ ) : resourcesError ? ( +
+

+ Error: {resourcesError} +

+

+ Go to Spawner +

+
+ ) : totalResources === 0 ? ( +
+

+ No resources available.{" "} + Go to Spawner +

+
+ ) : ( + groups.map((group) => ( +
+
+

{group.displayName}

+ + {group.resources.length}{" "} + {group.resources.length === 1 ? "resource" : "resources"} + +
+ +
+ )) + )} +
+
+ + {/* Docs + News */} +
+
+
+ +
+
+

News & Updates

+
+
+ {announcement && ( +
+
Announcement
+

Platform Announcement

+

{announcement}

+
+ )} +
+
Platform
+

Welcome to AUP Learning Cloud

+

+ Get started with GPU-accelerated Jupyter notebooks powered + by AMD ROCm technology. +

+
+
+
+
+
+
+ + {/* Footer */} +
+
+ © 2025–2026 Advanced Micro Devices, Inc. All rights reserved. + · + AUP Learning Cloud +
+
+
+ ); +} + +export default App; diff --git a/runtime/hub/frontend/apps/home/src/main.tsx b/runtime/hub/frontend/apps/home/src/main.tsx new file mode 100644 index 0000000..628b690 --- /dev/null +++ b/runtime/hub/frontend/apps/home/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; +import "./styles.css"; + +const rootElement = + document.getElementById("home-root") ?? document.getElementById("root"); + +if (rootElement) { + createRoot(rootElement).render( + + + , + ); +} else { + console.error("Could not find root element to mount React app"); +} diff --git a/runtime/hub/frontend/apps/home/src/styles.css b/runtime/hub/frontend/apps/home/src/styles.css new file mode 100644 index 0000000..a22a565 --- /dev/null +++ b/runtime/hub/frontend/apps/home/src/styles.css @@ -0,0 +1,587 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&display=swap'); + +/* ================================================================ + AUP Learning Cloud – Home Page + Slate palette: matches spawner, clean and understated + ================================================================ */ +:root { + --home-primary: #2c3e50; + --home-primary-hover: #1a252f; + --home-green: #34c759; + --home-red: #c0392b; + --home-surface-0: #ffffff; + --home-surface-1: #f5f5f7; + --home-surface-2: #e8e8ed; + --home-text: #1d1d1f; + --home-text-secondary: #86868b; + --home-text-muted: #aeaeb2; + --home-border: rgba(0,0,0,0.08); + --home-radius: 16px; + --home-radius-sm: 12px; + --home-hover-shadow: 0 4px 18px rgba(0,0,0,0.08); +} +[data-bs-theme="dark"] { + --home-primary: #86a8c7; + --home-primary-hover: #9dbdd8; + --home-green: #30d158; + --home-red: #ff453a; + --home-surface-0: #1c1c1e; + --home-surface-1: #2c2c2e; + --home-surface-2: #3a3a3c; + --home-text: #f5f5f7; + --home-text-secondary: #98989d; + --home-text-muted: #636366; + --home-border: rgba(255,255,255,0.08); + --home-hover-shadow: 0 4px 18px rgba(0,0,0,0.25); +} + +.home-page { + font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.home-page .container { + max-width: 1120px; + margin: 0 auto; + padding: 0 1.5rem; +} + +/* ---- Hero ---- */ +.home-hero { + background: #000000; + color: #fff; + padding: 3.5rem 0 4rem; + position: relative; + overflow: hidden; + text-align: center; +} +[data-bs-theme="dark"] .home-hero { background: #000000; } +.home-hero::before { + content: ''; + position: absolute; + top: -50%; left: 50%; + transform: translateX(-50%); + width: 800px; height: 800px; + background: radial-gradient(circle, rgba(44,62,80,0.08) 0%, transparent 60%); + pointer-events: none; +} +.home-hero h1 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 2.5rem; + font-weight: 700; + line-height: 1.15; + margin-bottom: 0.75rem; + letter-spacing: -0.025em; + position: relative; + z-index: 1; +} +.home-hero h1 .accent { + background: linear-gradient(135deg, #86a8c7 0%, #b0cfe0 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.home-hero .hero-desc { + font-size: 1.05rem; + color: rgba(255,255,255,0.5); + max-width: 520px; + margin: 0 auto; + position: relative; + z-index: 1; + font-weight: 400; + line-height: 1.5; +} + +/* ---- Launch bar ---- */ +.launch-bar { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.25rem 1.5rem; + display: flex; + align-items: center; + gap: 1.25rem; + margin-top: -2rem; + position: relative; + z-index: 10; + box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06); +} +[data-bs-theme="dark"] .launch-bar { + box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 8px 24px rgba(0,0,0,0.2); +} +.lb-icon { + width: 44px; height: 44px; + border-radius: var(--home-radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.15rem; + flex-shrink: 0; + color: #fff; +} +.lb-icon.stopped { background: var(--home-primary); } +.lb-icon.running { background: var(--home-green); } +.lb-info { flex: 1; } +.lb-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--home-text); + display: flex; + align-items: center; + gap: 0.5rem; +} +.lb-desc { + font-size: 0.8rem; + color: var(--home-text-secondary); + margin-top: 1px; +} +.lb-error { + color: var(--home-red); + font-weight: 500; +} +.status-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.7rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 100px; +} +.status-badge.stopped { + background: var(--home-surface-1); + color: var(--home-text-secondary); +} +.status-badge.running { + background: rgba(52,199,89,0.12); + color: #248a3d; +} +[data-bs-theme="dark"] .status-badge.running { + background: rgba(48,209,88,0.15); + color: #30d158; +} +.status-dot { + width: 6px; height: 6px; + border-radius: 50%; + display: inline-block; +} +.status-dot.stopped { background: var(--home-text-muted); } +.status-dot.running { + background: var(--home-green); +} +.lb-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* ---- Buttons ---- */ +.btn-launch { + display: inline-flex; + align-items: center; + gap: 0.45rem; + background: var(--home-primary); + color: #fff; + border: none; + padding: 0.6rem 1.5rem; + border-radius: 980px; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.15s; + text-decoration: none; + font-family: inherit; + white-space: nowrap; +} +.btn-launch:hover { + background: var(--home-primary-hover); + transform: scale(1.02); + color: #fff; + text-decoration: none; +} +.btn-launch:active { + transform: scale(0.98); +} +.btn-home-sm { + padding: 0.4rem 0.9rem; + border-radius: 980px; + font-size: 0.8rem; + font-weight: 600; + border: 1px solid var(--home-border); + background: var(--home-surface-0); + cursor: pointer; + transition: all 0.15s; + color: var(--home-text); + font-family: inherit; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.35rem; +} +.btn-home-sm:hover { + background: var(--home-surface-1); + text-decoration: none; + color: var(--home-text); +} +.btn-home-sm.danger { + background: transparent; + color: var(--home-red); + border-color: var(--home-red); +} +.btn-home-sm.danger:hover { + background: var(--home-red); + color: #fff; +} + +/* ---- Sections ---- */ +.home-section { + padding: 2.25rem 0; +} +.home-section-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 1.25rem; +} +.home-section-header h2 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 1.3rem; + font-weight: 700; + color: var(--home-text); + letter-spacing: -0.01em; +} +.home-section-header a { + font-size: 0.82rem; + color: var(--home-primary); + text-decoration: none; + font-weight: 500; +} +.home-section-header a:hover { text-decoration: underline; } + +/* ---- Quick Start (compact strip) ---- */ +.qs-strip { + display: flex; + gap: 1.25rem; + align-items: stretch; +} +.qs-step { + flex: 1; + display: flex; + align-items: flex-start; + gap: 0.6rem; + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius-sm); + padding: 0.85rem 1rem; +} +.qs-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; height: 26px; + min-width: 26px; + background: var(--home-primary); + color: #fff; + border-radius: 50%; + font-size: 0.72rem; + font-weight: 700; +} +[data-bs-theme="dark"] .qs-num { + background: var(--home-primary); + color: #fff; +} +.qs-step-text h4 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.82rem; + font-weight: 600; + margin-bottom: 0.1rem; + color: var(--home-text); +} +.qs-step-text p { + font-size: 0.72rem; + color: var(--home-text-secondary); + line-height: 1.4; + margin: 0; +} + +/* ---- Resource Groups ---- */ +.resource-group { + margin-bottom: 1.5rem; +} +.resource-group-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} +.resource-group-header h3 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.95rem; + font-weight: 600; + color: var(--home-text); + margin: 0; +} +.group-count { + font-size: 0.72rem; + font-weight: 500; + color: var(--home-text-muted); + background: var(--home-surface-2); + padding: 2px 8px; + border-radius: 100px; +} + +.resources-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.85rem; +} + +.resources-loading, +.resources-empty { + text-align: center; + padding: 2rem; + color: var(--home-text-secondary); + font-size: 0.88rem; +} +.resources-loading i { + margin-right: 0.4rem; +} +.resources-empty a { + color: var(--home-primary); + text-decoration: none; + font-weight: 500; +} +.resources-empty a:hover { text-decoration: underline; } +.resources-error { + text-align: center; + padding: 1.5rem 2rem; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: var(--home-radius-sm); + color: #7c6a0a; + font-size: 0.85rem; +} +[data-bs-theme="dark"] .resources-error { + background: rgba(255,193,7,0.1); + border-color: rgba(255,193,7,0.25); + color: #ffc107; +} +.resources-error a { + color: var(--home-primary); + text-decoration: none; + font-weight: 500; +} +.resources-error a:hover { text-decoration: underline; } + +/* ---- Resource Card ---- */ +.resource-card { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.15rem 1.25rem; + transition: box-shadow 0.25s ease, transform 0.2s ease, border-color 0.2s ease; + cursor: pointer; + text-decoration: none; + color: var(--home-text); + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.resource-card:hover { + box-shadow: var(--home-hover-shadow); + transform: translateY(-2px); + text-decoration: none; + color: var(--home-text); + border-color: var(--home-primary); +} +.resource-card-top { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} +.resource-card-info { + flex: 1; + min-width: 0; +} +.resource-card-info h4 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.88rem; + font-weight: 600; + margin-bottom: 0.15rem; + color: var(--home-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.resource-card-info p { + font-size: 0.75rem; + color: var(--home-text-secondary); + line-height: 1.45; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.resource-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} +.resource-card-arrow { + font-size: 0.75rem; + color: var(--home-text-muted); + margin-left: auto; + transition: transform 0.2s, color 0.2s; + flex-shrink: 0; +} +.resource-card:hover .resource-card-arrow { + transform: translateX(3px); + color: var(--home-primary); +} + + +/* ---- Resource tags ---- */ +.resource-tag { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.62rem; + font-weight: 600; + padding: 0.15rem 0.5rem; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.tag-gpu { background: #f3e8fd; color: #9334e6; } +.tag-cpu { background: #e8f0fe; color: #1a73e8; } +.tag-npu { background: #e6f4ea; color: #1e8e3e; } +.tag-spec { + background: var(--home-surface-1); + color: var(--home-text-secondary); + font-weight: 500; + text-transform: none; + letter-spacing: 0; +} +.tag-git { + background: #eaf6e8; + color: #2d6a27; + border: 1px solid #b7ddb0; +} +[data-bs-theme="dark"] .tag-gpu { background: rgba(147,52,230,0.15); color: #c084fc; } +[data-bs-theme="dark"] .tag-cpu { background: rgba(26,115,232,0.15); color: #60a5fa; } +[data-bs-theme="dark"] .tag-npu { background: rgba(30,142,62,0.15); color: #4ade80; } +[data-bs-theme="dark"] .tag-spec { background: var(--home-surface-2); color: var(--home-text-secondary); } +[data-bs-theme="dark"] .tag-git { background: rgba(45,106,39,0.15); color: #4ade80; border-color: rgba(45,106,39,0.3); } + +/* ---- Two-column layout ---- */ +.home-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +/* ---- Doc list ---- */ +.doc-list { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + overflow: hidden; +} +.doc-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.8rem 1.15rem; + border-bottom: 1px solid var(--home-border); + text-decoration: none; + color: var(--home-text); + transition: background 0.15s; +} +.doc-item:last-child { border-bottom: none; } +.doc-item:hover { + background: var(--home-surface-1); + text-decoration: none; + color: var(--home-text); +} +.doc-icon { + width: 36px; height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + flex-shrink: 0; +} +.doc-text h4 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.85rem; + font-weight: 600; + margin: 0; + color: var(--home-text); +} +.doc-text p { + font-size: 0.75rem; + color: var(--home-text-secondary); + margin: 0; +} + +/* ---- News ---- */ +.news-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.news-card { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.1rem 1.25rem; + transition: box-shadow 0.25s ease; +} +.news-card:hover { box-shadow: var(--home-hover-shadow); } +.news-meta { + font-size: 0.68rem; + color: var(--home-text-muted); + margin-bottom: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} +.news-card h4 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.88rem; + font-weight: 600; + margin-bottom: 0.2rem; + color: var(--home-text); +} +.news-card p { + font-size: 0.78rem; + color: var(--home-text-secondary); + line-height: 1.55; + margin: 0; +} + +/* ---- Footer ---- */ +.home-footer { + border-top: 1px solid var(--home-border); + padding: 1.5rem 0; + text-align: center; + font-size: 0.78rem; + color: var(--home-text-muted); + margin-top: 1rem; +} + +/* ---- Responsive ---- */ +@media (max-width: 900px) { + .resources-grid { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 768px) { + .home-hero h1 { font-size: 1.8rem; } + .resources-grid { grid-template-columns: 1fr; } + .home-two-col { grid-template-columns: 1fr; } + .launch-bar { flex-direction: column; text-align: center; } + .lb-actions { justify-content: center; } + .qs-strip { flex-direction: column; } +} diff --git a/runtime/hub/frontend/apps/home/tsconfig.app.json b/runtime/hub/frontend/apps/home/tsconfig.app.json new file mode 100644 index 0000000..c328724 --- /dev/null +++ b/runtime/hub/frontend/apps/home/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/runtime/hub/frontend/apps/home/tsconfig.json b/runtime/hub/frontend/apps/home/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/runtime/hub/frontend/apps/home/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/runtime/hub/frontend/apps/home/tsconfig.node.json b/runtime/hub/frontend/apps/home/tsconfig.node.json new file mode 100644 index 0000000..ddae9c9 --- /dev/null +++ b/runtime/hub/frontend/apps/home/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/runtime/hub/frontend/apps/home/vite.config.ts b/runtime/hub/frontend/apps/home/vite.config.ts new file mode 100644 index 0000000..0c0b4ea --- /dev/null +++ b/runtime/hub/frontend/apps/home/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: '/hub/static/home-ui/', + build: { + outDir: 'dist', + rollupOptions: { + output: { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/[name].[ext]', + }, + }, + }, +}) diff --git a/runtime/hub/frontend/apps/spawn/src/App.tsx b/runtime/hub/frontend/apps/spawn/src/App.tsx index 9a0df4f..3917a69 100644 --- a/runtime/hub/frontend/apps/spawn/src/App.tsx +++ b/runtime/hub/frontend/apps/spawn/src/App.tsx @@ -25,46 +25,22 @@ import { useResources } from './hooks/useResources'; import { useAccelerators } from './hooks/useAccelerators'; import { useQuota } from './hooks/useQuota'; -/** - * Normalize a repo URL typed by the user: - * - Trims whitespace - * - Prepends https:// if no protocol is present - * - Strips /tree/ (GitHub/GitLab style) and returns branch separately - * - Strips trailing .git suffix - * Returns { url, branch } where url is the clean clone URL. - */ function normalizeRepoUrl(raw: string): { url: string; branch: string } { let s = raw.trim(); if (!s) return { url: '', branch: '' }; - - if (!s.includes('://')) { - s = 'https://' + s; - } - + if (!s.includes('://')) s = 'https://' + s; let branch = ''; try { const parsed = new URL(s); let path = parsed.pathname; - - // Strip /tree/ (GitHub: /owner/repo/tree/main) const treeMatch = path.match(/^(\/[^/]+\/[^/]+)\/tree\/(.+)$/); - if (treeMatch) { - path = treeMatch[1]; - branch = treeMatch[2]; - } - - if (path.endsWith('.git')) { - path = path.slice(0, -4); - } - + if (treeMatch) { path = treeMatch[1]; branch = treeMatch[2]; } + if (path.endsWith('.git')) path = path.slice(0, -4); parsed.pathname = path; - // Remove any query string or hash that may have been pasted parsed.search = ''; parsed.hash = ''; return { url: parsed.toString(), branch }; - } catch { - return { url: s, branch: '' }; - } + } catch { return { url: s, branch: '' }; } } function validateRepoUrl(url: string, allowedProviders: string[]): string { @@ -77,9 +53,7 @@ function validateRepoUrl(url: string, allowedProviders: string[]): string { p => hostname === p || hostname.endsWith('.' + p) ); if (!allowed) return `Host not allowed. Supported: ${allowedProviders.join(', ')}.`; - } catch { - return 'Invalid URL format.'; - } + } catch { return 'Invalid URL format.'; } return ''; } @@ -111,15 +85,12 @@ function App() { const [githubRepos, setGithubRepos] = useState([]); const [githubAppInstalled, setGithubAppInstalled] = useState(false); - // Derive branch and shareable /hub/git/ link from raw input const { branch: repoBranch, url: normalizedRepoUrl } = useMemo( - () => normalizeRepoUrl(repoUrl), - [repoUrl] + () => normalizeRepoUrl(repoUrl), [repoUrl] ); const loading = resourcesLoading || acceleratorsLoading || quotaLoading; - // Validate initial repo_url from query params once providers are loaded useEffect(() => { if (!initialRepoUrl || allowedGitProviders.length === 0) return; const { url } = normalizeRepoUrl(initialRepoUrl); @@ -127,83 +98,55 @@ function App() { if (err) setRepoUrlError(err); }, [allowedGitProviders, initialRepoUrl]); - // Pre-select resource from query param or autostart default, then auto-submit if needed const hasAutoSelected = useRef(false); useEffect(() => { if (resourcesLoading || resources.length === 0 || hasAutoSelected.current) return; - let target: Resource | undefined; if (initialResourceKey) { target = resources.find(r => r.key === initialResourceKey); - if (!target) { - setParamWarning(`Unknown resource '${initialResourceKey}', using default.`); - } - } - if (!target && (autostart || initialRepoUrl)) { - target = resources.find(r => r.metadata?.allowGitClone); - } - // Fallback: pre-selection was attempted but failed → select first resource - if (!target && (initialResourceKey || autostart || initialRepoUrl)) { - target = resources[0]; + if (!target) setParamWarning(`Unknown resource '${initialResourceKey}', using default.`); } + if (!target && (autostart || initialRepoUrl)) target = resources.find(r => r.metadata?.allowGitClone); + if (!target && (initialResourceKey || autostart || initialRepoUrl)) target = resources[0]; if (target) { hasAutoSelected.current = true; setSelectedResource(target); - // Expand the group containing the pre-selected resource const targetGroup = groups.find(g => g.resources.some(r => r.key === target!.key)); if (targetGroup) setExpandedGroup(targetGroup.name); if (initialAcceleratorKey) { const validKeys = target.metadata?.acceleratorKeys ?? []; - if (validKeys.includes(initialAcceleratorKey)) { - setSelectedAcceleratorKey(initialAcceleratorKey); - } else if (initialAcceleratorKey) { - setParamWarning(`Unknown accelerator '${initialAcceleratorKey}' for this resource, using default.`); - } + if (validKeys.includes(initialAcceleratorKey)) setSelectedAcceleratorKey(initialAcceleratorKey); + else if (initialAcceleratorKey) setParamWarning(`Unknown accelerator '${initialAcceleratorKey}' for this resource, using default.`); } } else { - // Default: expand the first group const firstGroup = groups.find(g => g.resources.length > 0); if (firstGroup) setExpandedGroup(firstGroup.name); } }, [resources, groups, resourcesLoading, initialResourceKey, initialAcceleratorKey, autostart, initialRepoUrl]); - // Auto-submit once resource is selected and form is ready useEffect(() => { if (!autostart || autostartFired.current) return; if (!selectedResource || loading) return; autostartFired.current = true; - // Brief delay to let the DOM settle before submitting setTimeout(() => { const form = document.getElementById('spawn_form') as HTMLFormElement | null; form?.submit(); }, 300); }, [autostart, selectedResource, loading]); - // Fetch GitHub repos when githubAppName is configured (GitHub OAuth users only) const isGitHub = isCurrentUserGitHub(); useEffect(() => { if (!githubAppName || !isGitHub) return; fetchGitHubRepos() - .then(data => { - setGithubRepos(data.repos); - setGithubAppInstalled(data.installed); - }) - .catch(() => { - // Silently fail - user just won't see repo picker - }); + .then(data => { setGithubRepos(data.repos); setGithubAppInstalled(data.installed); }) + .catch(() => {}); }, [githubAppName, isGitHub]); - // Compute available accelerators based on selected resource const availableAccelerators = useMemo(() => { - if (!selectedResource?.metadata?.acceleratorKeys) { - return []; - } - return accelerators.filter(acc => - selectedResource.metadata?.acceleratorKeys?.includes(acc.key) - ); + if (!selectedResource?.metadata?.acceleratorKeys) return []; + return accelerators.filter(acc => selectedResource.metadata?.acceleratorKeys?.includes(acc.key)); }, [selectedResource, accelerators]); - // Derive selected accelerator: use user selection if valid, otherwise first available const selectedAccelerator = useMemo(() => { if (availableAccelerators.length === 0) return null; const userSelected = availableAccelerators.find(acc => acc.key === selectedAcceleratorKey); @@ -227,35 +170,26 @@ function App() { return `${spawnBase}?${params.toString()}`; }, [normalizedRepoUrl, repoBranch, repoUrlError, allowGitClone, selectedResource, selectedAccelerator]); - // Memoize quota calculations const { cost, canAfford, insufficientQuota, maxRuntime } = useMemo(() => { const rate = selectedAccelerator?.quotaRate ?? quota?.rates?.cpu ?? 1; const calculatedCost = quota?.enabled ? rate * runtime : 0; const balance = quota?.balance ?? 0; - return { cost: calculatedCost, canAfford: quota?.unlimited || balance >= calculatedCost, insufficientQuota: quota?.enabled && !quota?.unlimited && balance < 10, - maxRuntime: quota?.enabled && !quota?.unlimited - ? Math.min(240, Math.floor(balance / rate)) - : 240, + maxRuntime: quota?.enabled && !quota?.unlimited ? Math.min(240, Math.floor(balance / rate)) : 240, }; }, [quota, selectedAccelerator?.quotaRate, runtime]); const canStart = selectedResource && canAfford && !repoUrlError && !repoValidating; - // Memoize non-empty groups filter - const nonEmptyGroups = useMemo( - () => groups.filter(g => g.resources.length > 0), - [groups] - ); + const nonEmptyGroups = useMemo(() => groups.filter(g => g.resources.length > 0), [groups]); + const totalResources = resources.length; - // Accordion: toggle group, only one open at a time const handleToggleGroup = useCallback((groupName: string) => { setExpandedGroup(prev => prev === groupName ? null : groupName); }, []); - // Memoize callbacks to prevent child re-renders const handleSelectResource = useCallback((resource: Resource) => { setSelectedResource(resource); }, []); @@ -271,26 +205,16 @@ function App() { setRepoUrlError(formatError); setRepoValidating(false); setRepoValid(false); - - // Clear pending validation if (validateTimerRef.current) clearTimeout(validateTimerRef.current); - - // If format is valid and URL is non-empty, debounce remote validation if (!formatError && url) { setRepoValidating(true); validateTimerRef.current = setTimeout(async () => { try { const result = await validateRepo(url, branch || undefined); - if (result.valid) { - setRepoValid(true); - } else { - setRepoUrlError(result.error); - } - } catch { - // API error — don't block the user - } finally { - setRepoValidating(false); - } + if (result.valid) setRepoValid(true); + else setRepoUrlError(result.error); + } catch { /* API error */ } + finally { setRepoValidating(false); } }, 800); } }, [allowedGitProviders]); @@ -298,29 +222,20 @@ function App() { const handleSelectGitHubRepo = useCallback((repo: GitHubRepo) => { const url = repo.html_url; setRepoUrl(url); - const { url: normalizedUrl, branch } = normalizeRepoUrl(url); - const formatError = validateRepoUrl(normalizedUrl, allowedGitProviders); + const { url: nUrl, branch } = normalizeRepoUrl(url); + const formatError = validateRepoUrl(nUrl, allowedGitProviders); setRepoUrlError(formatError); setRepoValid(false); - if (validateTimerRef.current) clearTimeout(validateTimerRef.current); - - if (!formatError && normalizedUrl) { + if (!formatError && nUrl) { setRepoValidating(true); validateTimerRef.current = setTimeout(async () => { try { - const result = await validateRepo(normalizedUrl, branch || undefined); - if (result.valid) { - setRepoValid(true); - setRepoUrlError(''); - } else { - setRepoUrlError(result.error); - } - } catch { - // API error - } finally { - setRepoValidating(false); - } + const result = await validateRepo(nUrl, branch || undefined); + if (result.valid) { setRepoValid(true); setRepoUrlError(''); } + else setRepoUrlError(result.error); + } catch { /* API error */ } + finally { setRepoValidating(false); } }, 300); } }, [allowedGitProviders]); @@ -332,25 +247,16 @@ function App() { const handleRuntimeChange = useCallback((e: React.ChangeEvent) => { setRuntimeInput(e.target.value); const value = parseInt(e.target.value); - if (!isNaN(value) && value > 0) { - setRuntime(value); - } + if (!isNaN(value) && value > 0) setRuntime(value); }, []); const handleRuntimeBlur = useCallback(() => { const value = parseInt(runtimeInput); const min = 10; const max = Math.min(240, maxRuntime); - if (isNaN(value) || value < min) { - setRuntime(min); - setRuntimeInput(String(min)); - } else if (value > max) { - setRuntime(max); - setRuntimeInput(String(max)); - } else { - setRuntime(value); - setRuntimeInput(String(value)); - } + if (isNaN(value) || value < min) { setRuntime(min); setRuntimeInput(String(min)); } + else if (value > max) { setRuntime(max); setRuntimeInput(String(max)); } + else { setRuntime(value); setRuntimeInput(String(value)); } }, [runtimeInput, maxRuntime]); if (loading) { @@ -363,32 +269,34 @@ function App() { } if (resourcesError) { - return ( -
- Error: {resourcesError} -
- ); + return
Error: {resourcesError}
; } + const homeUrl = `${window.jhdata?.base_url ?? '/hub/'}home`; + const acceleratorType = selectedResource?.metadata?.accelerator ?? 'GPU'; + return ( <> - {/* Hidden inputs for form submission */} + {/* Hidden form inputs */} {selectedResource && ( - + )} - {/* Invalid query param warning */} - {paramWarning && ( -
- Warning: {paramWarning} + {allowGitClone && } + + {/* Page header */} +
+
+ Home + / + Launch Server
- )} +

Launch Your Server

+

Select a resource, configure your environment, and launch

+
- {/* Insufficient quota warning */} + {/* Warnings */} + {paramWarning &&
Warning: {paramWarning}
} {insufficientQuota && (
Insufficient Quota
@@ -396,104 +304,180 @@ function App() {
)} - {/* Resource list */} {resources.length === 0 ? (
No resources available
Please contact administrator for access.
) : ( - <> -
- {nonEmptyGroups.map((group) => ( - - ))} -
- - {/* Runtime input */} -
- - - - {/* Quota cost preview */} - {quota?.enabled && !quota?.unlimited && ( -
- Estimated cost: - {cost} - quota (Remaining: - {(quota?.balance ?? 0) - cost} - ) -
- )} +
+ {/* LEFT: Resource picker */} +
+
+

Choose a Resource

+ {totalResources} {totalResources === 1 ? 'resource' : 'resources'} +
+
+ {nonEmptyGroups.map((group) => ( + + ))} +
- {/* Shareable link - available for all resources */} - {shareableUrl && ( -
- Share link: - {shareableUrl} - + + {quota?.enabled && ( +
+ Quota:{' '} + + {quota?.unlimited ? 'Unlimited' : quota?.balance ?? 0} + +
+ )}
- )} - - {/* Launch section */} -
- - - {quota?.enabled && ( - - Quota: - {quota?.unlimited ? 'Unlimited' : quota?.balance ?? 0} - - + + {/* Share link */} + {shareableUrl && ( +
+ Share link: + {shareableUrl} + +
)} -
- + +
)} ); diff --git a/runtime/hub/frontend/apps/spawn/src/styles.css b/runtime/hub/frontend/apps/spawn/src/styles.css index 06d0a66..4397adb 100644 --- a/runtime/hub/frontend/apps/spawn/src/styles.css +++ b/runtime/hub/frontend/apps/spawn/src/styles.css @@ -1,707 +1,374 @@ /* Copyright (C) 2025 Advanced Micro Devices, Inc. All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + SPDX-License-Identifier: MIT */ -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap'); -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ - -/* ========== Original Spawn UI Styles ========== */ +/* ================================================================ + Spawn UI — 方案三 two-column layout + Left: resource picker | Right: sticky config sidebar + ================================================================ */ :root { - --dark-bg-primary: #212529; - --dark-bg-secondary: #2b3035; - --dark-bg-tertiary: #343a40; - --dark-border: #495057; - --dark-text-primary: #e9ecef; - --dark-text-secondary: #adb5bd; - --dark-text-muted: #6c757d; -} - -/* Main container */ -[data-bs-theme="dark"] #spawn-root { background: var(--dark-bg-secondary) !important; } - -/* Resource container */ -[data-bs-theme="dark"] .resource-container { background: var(--dark-bg-secondary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .resource-container:hover { background-color: var(--dark-bg-tertiary) !important; } -[data-bs-theme="dark"] .resource-container.selected { background-color: var(--dark-bg-tertiary) !important; border-color: var(--dark-text-secondary) !important; box-shadow: 0 0 0 1px var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .resource-container strong { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .env-desc { color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .dot { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .resource-tag { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-border) !important; color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .git-clone-badge { background: #1a3a18 !important; border-color: #2d5a28 !important; color: #7ec87a !important; } - -/* Category section */ -[data-bs-theme="dark"] .resource-category { border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .resource-category-header { background: var(--dark-bg-tertiary) !important; color: var(--dark-text-primary) !important; border-bottom-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .resource-category-header:hover { background: linear-gradient(to right, var(--dark-bg-tertiary), #3d444b) !important; } -[data-bs-theme="dark"] .resource-category-header h5 { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .collapse-icon { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .collapsible-content { background: var(--dark-bg-secondary) !important; } - -/* GPU selection */ -[data-bs-theme="dark"] .gpu-selection { background: var(--dark-bg-secondary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .gpu-selection h6 { color: var(--dark-text-primary) !important; border-bottom-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .gpu-option { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .gpu-option:hover { background: #3d444b !important; border-color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .gpu-option.selected { background: #3d444b !important; border-color: var(--dark-text-secondary) !important; box-shadow: 0 0 0 1px var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .gpu-option-name { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .gpu-option-desc { color: var(--dark-text-secondary) !important; } - -/* Runtime section */ -[data-bs-theme="dark"] .runtime-container { border-top-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .runtime-container label { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .runtime-container input { background: var(--dark-bg-tertiary) !important; color: var(--dark-text-primary) !important; border-color: var(--dark-border) !important; } - -/* Launch section */ -[data-bs-theme="dark"] .launch-button { background: var(--dark-text-primary) !important; color: var(--dark-bg-primary) !important; } -[data-bs-theme="dark"] .launch-button:hover:not(:disabled) { background: #ffffff !important; } -[data-bs-theme="dark"] .quota-display-simple { color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .quota-cost-preview { color: var(--dark-text-secondary) !important; } - -/* Repo URL (inside gpu-selection card) */ -[data-bs-theme="dark"] .repo-url-input { background: var(--dark-bg-tertiary) !important; color: var(--dark-text-primary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .repo-url-input:focus { border-color: var(--dark-text-secondary) !important; box-shadow: 0 0 0 3px rgba(173,181,189,0.15) !important; } -[data-bs-theme="dark"] .repo-url-input.input-error { border-color: #dc3545 !important; } -[data-bs-theme="dark"] .repo-url-input.input-valid { border-color: #2d6a27 !important; } -[data-bs-theme="dark"] .repo-url-hint { background: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-url-tooltip { background: var(--dark-bg-primary) !important; color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .repo-url-tooltip::after { border-top-color: var(--dark-bg-primary) !important; } -[data-bs-theme="dark"] .repo-url-error { color: #f8888e !important; } -[data-bs-theme="dark"] .repo-url-validating { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-url-success { color: #7ec87a !important; } -[data-bs-theme="dark"] .shareable-link { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .shareable-link-label { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .shareable-link-url { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .shareable-link-copy { background: var(--dark-bg-secondary) !important; border-color: var(--dark-border) !important; color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .shareable-link-copy:hover { background: var(--dark-bg-primary) !important; } - -/* Warning box */ -[data-bs-theme="dark"] .warning-box { background: #4a3f2a !important; border-color: #6b5c3a !important; color: #ffc107 !important; } - -/* Loading spinner */ -[data-bs-theme="dark"] .loading-spinner { color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .spinner-icon { border-color: var(--dark-border) !important; border-top-color: var(--dark-text-primary) !important; } - + --home-primary: #2c3e50; + --home-primary-hover: #1a252f; + --home-green: #34c759; + --home-red: #c0392b; + --home-surface-0: #ffffff; + --home-surface-1: #f5f5f7; + --home-surface-2: #e8e8ed; + --home-text: #1d1d1f; + --home-text-secondary: #86868b; + --home-text-muted: #aeaeb2; + --home-border: rgba(0,0,0,0.08); + --home-radius: 16px; + --home-radius-sm: 12px; + --home-hover-shadow: 0 4px 18px rgba(0,0,0,0.08); +} +[data-bs-theme="dark"] { + --home-primary: #86a8c7; + --home-primary-hover: #9dbdd8; + --home-green: #30d158; + --home-red: #ff453a; + --home-surface-0: #1c1c1e; + --home-surface-1: #2c2c2e; + --home-surface-2: #3a3a3c; + --home-text: #f5f5f7; + --home-text-secondary: #98989d; + --home-text-muted: #636366; + --home-border: rgba(255,255,255,0.08); + --home-hover-shadow: 0 4px 18px rgba(0,0,0,0.25); +} + +/* ---- Root container ---- */ #spawn-root { - max-width: 1000px; - margin: 0 auto; - padding: 25px; - background: #f8f9fa; - border-radius: 16px; - box-shadow: 0 2px 6px rgba(0,0,0,0.05); -} - -.resource-category { - margin-bottom: 20px; - border: 1px solid #e0e0e0; - border-radius: 12px; - overflow: visible; - box-shadow: 0 1px 3px rgba(0,0,0,0.05); -} - -.collapsed .resource-category, -.resource-category.collapsed { - overflow: hidden; -} - -.resource-category-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - background: #f8f9fa; - cursor: pointer; - border-bottom: 1px solid #e0e0e0; - transition: background 0.2s; -} - -.resource-category-header:hover { - background: linear-gradient(to right, #eef1f5, #e8ecf2); -} - -.resource-category-header h5 { - margin: 0; - color: #2c3e50; - font-size: 16px; - font-weight: 600; -} - -.collapse-icon { - font-size: 16px; - transition: transform 0.2s ease; - color: #2c3e50; -} - -.collapsed .collapse-icon { - transform: rotate(-90deg); -} - -.collapsible-content { - max-height: 2000px; - overflow: visible; - transition: max-height 0.3s ease-out, padding 0.3s ease-out; - background: #ffffff; - padding: 20px; -} - -.collapsed .collapsible-content { - max-height: 0; - padding: 0 20px; - overflow: hidden; -} - -.resource-container { - margin-bottom: 14px; - padding: 16px; - border: 1px solid #e0e0e0; - border-radius: 10px; - display: flex; - flex-direction: column; - background: white; - box-shadow: 0 1px 3px rgba(0,0,0,0.05); - cursor: pointer; - transition: background-color 0.2s ease; -} - -.resource-container:hover { - background-color: #f9fafb; -} - -.resource-container.selected { - background-color: #f5f7fa; - border-color: #2c3e50; - box-shadow: 0 0 0 1px #2c3e50; -} + max-width: 1120px; + margin: 0 auto; + padding: 1.5rem; + background: transparent; + border-radius: 0; + box-shadow: none; + font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; + -webkit-font-smoothing: antialiased; + color: var(--home-text); +} +[data-bs-theme="dark"] #spawn-root { background: transparent !important; } + +/* ---- Spawn header ---- */ +.spawn-header { text-align: center; margin-bottom: 1.5rem; } +.spawn-breadcrumb { + display: inline-flex; align-items: center; gap: 0.4rem; + font-size: 0.78rem; color: var(--home-text-muted); margin-bottom: 0.75rem; +} +.spawn-breadcrumb a { color: var(--home-primary); text-decoration: none; font-weight: 500; } +.spawn-breadcrumb a:hover { text-decoration: underline; } +.spawn-header h1 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; + margin-bottom: 0.3rem; color: var(--home-text); +} +.spawn-header p { font-size: 0.88rem; color: var(--home-text-secondary); } + +/* ---- Two-column layout ---- */ +.spawn-layout { + display: grid; + grid-template-columns: 1fr 360px; + gap: 1.5rem; + align-items: start; +} + +/* ---- LEFT: Resource picker ---- */ +.spawn-picker { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + overflow: hidden; +} +.picker-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--home-border); + display: flex; align-items: center; justify-content: space-between; +} +.picker-header h2 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 1.05rem; font-weight: 700; color: var(--home-text); +} +.picker-count { + font-size: 0.72rem; color: var(--home-text-muted); + background: var(--home-surface-2); padding: 2px 8px; border-radius: 100px; +} +.picker-body { /* categories render inside */ } + +/* Hide inline GPU/Git panels from CourseCard — sidebar handles them */ +.spawn-picker .gpu-selection { display: none; } + +/* Category accordion — fits inside picker */ +.spawn-picker .resource-category { + margin-bottom: 0; + border: none; + border-bottom: 1px solid var(--home-border); + border-radius: 0; + box-shadow: none; + background: transparent; +} +.spawn-picker .resource-category:last-child { border-bottom: none; } +.spawn-picker .resource-category-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + background: var(--home-surface-0); + border-bottom: 1px solid var(--home-border); + cursor: pointer; + transition: background 0.15s; +} +.spawn-picker .resource-category-header:hover { background: var(--home-surface-1); } +.spawn-picker .resource-category-header h5 { + margin: 0; color: var(--home-text); + font-size: 0.88rem; font-weight: 600; + font-family: 'Plus Jakarta Sans', sans-serif; + flex: 1; min-width: 0; +} +.spawn-picker .collapse-icon { font-size: 0.7rem; color: var(--home-text-muted); } +.spawn-picker .collapsed .collapse-icon { transform: rotate(-90deg); } + +.spawn-picker .collapsible-content { + background: var(--home-surface-1); + padding: 0.75rem 1rem; + max-height: 2000px; + overflow: visible; + transition: max-height 0.3s ease-out, padding 0.3s ease-out; +} +.spawn-picker .collapsed .collapsible-content { + max-height: 0; padding: 0 1rem; overflow: hidden; +} + +/* Resource cards in picker */ +.spawn-picker .resource-container { + margin-bottom: 0.5rem; + padding: 0.75rem 0.85rem; + border: 2px solid var(--home-border); + border-radius: 10px; + display: flex; flex-direction: column; + background: var(--home-surface-0); + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.15s; +} +.spawn-picker .resource-container:last-child { margin-bottom: 0; } +.spawn-picker .resource-container:hover { border-color: rgba(44,62,80,0.2); } +.spawn-picker .resource-container.selected { + border-color: var(--home-primary); + box-shadow: 0 0 0 1px var(--home-primary); + background: rgba(44,62,80,0.015); +} +[data-bs-theme="dark"] .spawn-picker .resource-container { background: var(--home-surface-0) !important; border-color: var(--home-border) !important; } +[data-bs-theme="dark"] .spawn-picker .resource-container:hover { border-color: rgba(134,168,199,0.3) !important; } +[data-bs-theme="dark"] .spawn-picker .resource-container.selected { border-color: var(--home-primary) !important; box-shadow: 0 0 0 1px var(--home-primary) !important; } .resource-container input[type="radio"] { - margin-right: 15px; - width: 16px; - height: 16px; - accent-color: #6c7a89; + margin-right: 0.7rem; width: 16px; height: 16px; + accent-color: var(--home-primary); flex-shrink: 0; cursor: pointer; } - .resource-container strong { - display: block; - font-size: 15px; - color: #2c3e50; - margin-bottom: 4px; + display: block; font-size: 0.85rem; font-weight: 600; + color: var(--home-text); margin-bottom: 0.1rem; + font-family: 'Plus Jakarta Sans', sans-serif; } +[data-bs-theme="dark"] .resource-container strong { color: var(--home-text) !important; } +.env-desc { color: var(--home-text-secondary); font-size: 0.72rem; } +[data-bs-theme="dark"] .env-desc { color: var(--home-text-secondary) !important; } +.dot { color: var(--home-text-muted); } +[data-bs-theme="dark"] .dot { color: var(--home-text-muted) !important; } -.env-desc { color: #34495e; } -.dot { color: #95a5a6; } .resource-tag { - color: #2c3e50; - background: #e8e8e8; - padding: 2px 8px; - border-radius: 3px; - border: 1px solid #d0d0d0; - font-size: 13px; + display: inline-flex; align-items: center; gap: 3px; + font-size: 0.6rem; font-weight: 600; padding: 1px 6px; border-radius: 5px; + background: var(--home-surface-1); color: var(--home-text-secondary); + border: none; font-family: 'Plus Jakarta Sans', sans-serif; } +[data-bs-theme="dark"] .resource-tag { background: var(--home-surface-2) !important; color: var(--home-text-secondary) !important; border: none !important; } .git-clone-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 7px; - border-radius: 3px; - border: 1px solid #b7ddb0; - background: #eaf6e8; - color: #2d6a27; - font-size: 12px; - font-weight: 500; - white-space: nowrap; -} - -.gpu-selection { - margin-top: 15px; - margin-left: 40px; - padding: 18px; - background: #ffffff; - border: 1px solid #e5e9f0; - border-radius: 12px; - box-shadow: 0 3px 8px rgba(0,0,0,0.06); -} - -.gpu-selection h6 { - color: #2e3440; - font-size: 1rem; - margin-bottom: 16px; - padding-bottom: 10px; - border-bottom: 2px solid #ebecf0; - font-weight: 600; -} - -.gpu-options-container { - max-height: 180px; - overflow-y: auto; - padding: 0 2px; - box-sizing: border-box; -} - -.gpu-option { - margin: 10px 0; - padding: 14px 16px; - background: #f8f9fc; - border: 1px solid #e5e9f0; - border-radius: 10px; - display: flex; - align-items: center; - cursor: pointer; - transition: all 0.2s; -} - -.gpu-option:hover { - background: #eceff4; - border-color: #2c3e50; -} - -.gpu-option.selected { - background: #e5e9f0; - border-color: #2c3e50; - box-shadow: 0 0 0 1px #2c3e50; -} - -.gpu-option input[type="radio"] { - margin-right: 12px; - width: 18px; - height: 18px; - accent-color: #2c3e50; -} - -.gpu-option-details { flex: 1; } -.gpu-option-name { font-weight: 600; color: #2e3440; margin-bottom: 4px; font-size: 0.95rem; } -.gpu-option-desc { font-size: 0.85rem; color: #4c566a; line-height: 1.4; } - -.runtime-container { - margin-top: 30px; - padding-top: 25px; - border-top: 1px solid #e0e0e0; - display: flex; - align-items: center; - flex-wrap: wrap; -} - -.runtime-container label { - color: #2c3e50; - margin-right: 15px; - font-weight: 500; -} - -.runtime-container input[type="number"] { - width: 120px; - padding: 10px 12px; - border: 1px solid #d0d7de; - border-radius: 8px; - font-size: 14px; - color: #2c3e50; -} - -.quota-cost-preview { - margin-left: 20px; - font-size: 14px; -} - -.launch-section { - margin-top: 25px; - display: flex; - align-items: center; - gap: 20px; -} - -.launch-button { - padding: 10px 20px; - font-weight: 500; - border-radius: 8px; - background: #2c3e50; - color: white; - border: none; - cursor: pointer; -} - -.launch-button:hover:not(:disabled) { - background: #1a252f; -} - -.launch-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.quota-display-simple { font-size: 14px; color: #666; } - + display: inline-flex; align-items: center; gap: 4px; + padding: 1px 6px; border-radius: 5px; + border: 1px solid #b7ddb0; background: #eaf6e8; color: #2d6a27; + font-size: 0.6rem; font-weight: 600; white-space: nowrap; +} +[data-bs-theme="dark"] .git-clone-badge { background: rgba(45,106,39,0.15) !important; border-color: rgba(45,106,39,0.3) !important; color: #4ade80 !important; } + +/* ---- RIGHT: Sidebar ---- */ +.spawn-sidebar { + position: sticky; + top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sidebar-panel { + background: var(--home-surface-0); + border: 1px solid var(--home-border); + border-radius: var(--home-radius); + padding: 1.25rem; + display: flex; flex-direction: column; gap: 1rem; +} +.sidebar-panel-title { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 0.95rem; font-weight: 700; color: var(--home-text); + padding-bottom: 0.65rem; border-bottom: 1px solid var(--home-border); +} +.sidebar-empty { + font-size: 0.82rem; color: var(--home-text-muted); + text-align: center; padding: 1rem 0; +} +.sidebar-section { display: flex; flex-direction: column; gap: 0.4rem; } +.sidebar-label { + font-size: 0.78rem; font-weight: 600; color: var(--home-text); + font-family: 'Plus Jakarta Sans', sans-serif; +} +.sidebar-optional { font-weight: 400; font-size: 0.68rem; color: var(--home-text-muted); } + +/* Sidebar GPU cards */ +.sidebar-gpu-list { display: flex; flex-direction: column; gap: 0.4rem; } +.sidebar-gpu-card { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.6rem 0.75rem; + border: 2px solid var(--home-border); border-radius: 10px; + cursor: pointer; background: var(--home-surface-0); + transition: border-color 0.15s, box-shadow 0.15s; +} +.sidebar-gpu-card:hover { border-color: rgba(44,62,80,0.25); } +.sidebar-gpu-card.selected { + border-color: var(--home-primary); + box-shadow: 0 0 0 1px var(--home-primary); +} +[data-bs-theme="dark"] .sidebar-gpu-card { background: var(--home-surface-1) !important; border-color: var(--home-border) !important; } +[data-bs-theme="dark"] .sidebar-gpu-card:hover { border-color: rgba(134,168,199,0.3) !important; } +[data-bs-theme="dark"] .sidebar-gpu-card.selected { border-color: var(--home-primary) !important; box-shadow: 0 0 0 1px var(--home-primary) !important; } +.sidebar-gpu-radio { width: 14px; height: 14px; accent-color: var(--home-primary); cursor: pointer; } +.sidebar-gpu-name { font-size: 0.78rem; font-weight: 600; color: var(--home-text); } +.sidebar-gpu-desc { font-size: 0.65rem; color: var(--home-text-secondary); line-height: 1.3; } + +/* Sidebar Git input */ +.sidebar-git-input { + width: 100%; padding: 0.5rem 0.75rem; + border: 1px solid var(--home-border); border-radius: 10px; + font-size: 0.78rem; color: var(--home-text); + font-family: 'Plus Jakarta Sans', sans-serif; + background: var(--home-surface-0); + transition: border-color 0.2s, box-shadow 0.2s; +} +.sidebar-git-input:focus { outline: none; border-color: var(--home-primary); box-shadow: 0 0 0 3px rgba(44,62,80,0.08); } +.sidebar-git-input.input-error { border-color: var(--home-red); box-shadow: 0 0 0 3px rgba(192,57,43,0.08); } +.sidebar-git-input.input-valid { border-color: var(--home-green); box-shadow: 0 0 0 3px rgba(52,199,89,0.08); } +[data-bs-theme="dark"] .sidebar-git-input { background: var(--home-surface-1) !important; color: var(--home-text) !important; border-color: var(--home-border) !important; } + +.sidebar-git-status { display: block; font-size: 0.68rem; margin-top: 0.25rem; } +.sidebar-git-status.loading { color: var(--home-text-muted); font-style: italic; } +.sidebar-git-status.success { color: #2d6a27; font-weight: 500; } +.sidebar-git-status.error { color: var(--home-red); } +[data-bs-theme="dark"] .sidebar-git-status.success { color: #4ade80; } +[data-bs-theme="dark"] .sidebar-git-status.error { color: #ff6b6b; } + +.sidebar-github-link { + display: inline-flex; align-items: center; gap: 0.3rem; + font-size: 0.72rem; color: var(--home-text-muted); + text-decoration: none; margin-top: 0.15rem; +} +.sidebar-github-link:hover { color: #2d6a27; text-decoration: underline; } + +/* Sidebar Runtime */ +.sidebar-runtime-row { display: flex; align-items: center; gap: 0.5rem; } +.sidebar-runtime-input { + width: 65px; padding: 0.4rem 0.5rem; + border: 1px solid var(--home-border); border-radius: 8px; + font-size: 0.78rem; text-align: center; + font-family: 'Plus Jakarta Sans', sans-serif; + color: var(--home-text); background: var(--home-surface-0); +} +.sidebar-runtime-input:focus { outline: none; border-color: var(--home-primary); box-shadow: 0 0 0 3px rgba(44,62,80,0.08); } +[data-bs-theme="dark"] .sidebar-runtime-input { background: var(--home-surface-1) !important; color: var(--home-text) !important; border-color: var(--home-border) !important; } +.sidebar-runtime-unit { font-size: 0.72rem; color: var(--home-text-secondary); } +.sidebar-quota-preview { font-size: 0.72rem; color: var(--home-text-secondary); margin-top: 0.25rem; } + +/* Sidebar Launch button */ +.sidebar-launch-btn { + display: flex; align-items: center; justify-content: center; gap: 0.5rem; + width: 100%; background: var(--home-primary); color: #fff; border: none; + padding: 0.75rem; border-radius: 980px; + font-size: 0.92rem; font-weight: 600; cursor: pointer; + transition: background 0.2s, transform 0.15s; + font-family: 'Plus Jakarta Sans', sans-serif; +} +.sidebar-launch-btn:hover:not(:disabled) { background: var(--home-primary-hover); transform: scale(1.01); } +.sidebar-launch-btn:active:not(:disabled) { transform: scale(0.98); } +.sidebar-launch-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } +[data-bs-theme="dark"] .sidebar-launch-btn { background: var(--home-primary) !important; color: #fff !important; } + +.sidebar-quota-display { + font-size: 0.78rem; color: var(--home-text-secondary); text-align: center; +} + +/* Sidebar share link */ +.sidebar-share { + display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; + padding: 0.5rem 0.85rem; + background: var(--home-surface-0); + border: 1px solid var(--home-border); border-radius: 10px; +} +.sidebar-share-label { font-size: 0.68rem; color: var(--home-text-muted); white-space: nowrap; font-weight: 500; } +.sidebar-share-url { font-size: 0.62rem; color: var(--home-text-secondary); word-break: break-all; flex: 1; } +.sidebar-share-copy { + padding: 0.2rem 0.5rem; border-radius: 5px; + border: 1px solid var(--home-border); background: var(--home-surface-0); + cursor: pointer; font-size: 0.62rem; font-weight: 500; + font-family: 'Plus Jakarta Sans', sans-serif; + color: var(--home-text-secondary); transition: background 0.15s; +} +.sidebar-share-copy:hover { background: var(--home-surface-2); } + +/* ---- Legacy classes (still used by old runtime/launch/warning) ---- */ .warning-box { - padding: 15px 20px; - background: #fff3cd; - border: 1px solid #ffc107; - border-radius: 8px; - color: #856404; - margin-bottom: 20px; -} - -.loading-spinner { - text-align: center; - padding: 40px; - color: #4c566a; + padding: 0.85rem 1.15rem; + background: #fffbe6; border: 1px solid #ffe58f; + border-radius: var(--home-radius-sm); + color: #7c6a0a; font-size: 0.82rem; margin-bottom: 1rem; } +[data-bs-theme="dark"] .warning-box { background: rgba(255,193,7,0.1) !important; border-color: rgba(255,193,7,0.25) !important; color: #ffc107 !important; } +.loading-spinner { text-align: center; padding: 3rem; color: var(--home-text-secondary); font-size: 0.88rem; } +[data-bs-theme="dark"] .loading-spinner { color: var(--home-text-secondary) !important; } .spinner-icon { - display: inline-block; - width: 24px; - height: 24px; - border: 3px solid #e5e9f0; - border-radius: 50%; - border-top-color: #2c3e50; - animation: spin 1s ease-in-out infinite; - margin-right: 12px; + display: inline-block; width: 24px; height: 24px; + border: 3px solid var(--home-border); border-radius: 50%; + border-top-color: var(--home-primary); + animation: spin 1s ease-in-out infinite; margin-right: 0.75rem; } - @keyframes spin { to { transform: rotate(360deg); } } - .feedback-container { display: none !important; } -.optional-label { - font-weight: 400; - font-size: 13px; - color: #6c757d; -} - -.repo-url-input { - width: 100%; - padding: 10px 12px; - border: 1px solid #d0d7de; - border-radius: 8px; - font-size: 14px; - color: #2c3e50; - box-sizing: border-box; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.repo-url-input:focus { - outline: none; - border-color: #2c3e50; - box-shadow: 0 0 0 3px rgba(94, 129, 172, 0.2); -} - -.repo-url-input.input-error { - border-color: #dc3545; - box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.15); -} - -.repo-url-input.input-valid { - border-color: #2d6a27; - box-shadow: 0 0 0 3px rgba(45, 106, 39, 0.15); -} - -.repo-url-error { - display: block; - margin-top: 6px; - font-size: 12px; - color: #dc3545; -} - -.repo-url-validating { - display: block; - margin-top: 6px; - color: #6c757d; - font-size: 12px; - font-style: italic; -} - -.repo-url-success { - display: block; - margin-top: 6px; - color: #2d6a27; - font-size: 12px; - font-weight: 500; -} - -.repo-branch-hint code { - font-size: 12px; - background: #eaf6e8; - padding: 1px 4px; - border-radius: 3px; +/* Legacy classes from CourseCard that are still rendered but hidden in sidebar mode */ +.optional-label { font-weight: 400; font-size: 0.68rem; color: var(--home-text-muted); } +.gpu-option, .gpu-option-details, .gpu-option-name, .gpu-option-desc, +.gpu-options-container, .gpu-selection h6 { /* kept for inline fallback */ } +.repo-url-input, .repo-url-error, .repo-url-validating, .repo-url-success, +.repo-url-hint, .repo-url-tooltip, .repo-branch-hint, +.repo-picker, .repo-picker-trigger, .repo-picker-dropdown, .repo-picker-filter, +.repo-picker-list, .repo-picker-item, .repo-picker-footer, .repo-picker-empty, +.repo-picker-name, .repo-picker-private, .repo-picker-chevron, +.repo-picker-trigger-text, .repo-picker-trigger-placeholder, +.github-app-prompt { /* kept for potential inline usage */ } + +/* Legacy bottom sections are hidden — sidebar replaces them */ +.runtime-container, .launch-section, .shareable-link { display: none; } + +/* ---- Responsive ---- */ +@media (max-width: 900px) { + .spawn-layout { + grid-template-columns: 1fr; + } + .spawn-sidebar { + position: static; + } +} +@media (max-width: 600px) { + #spawn-root { padding: 1rem; } } - -.shareable-link { - display: flex; - align-items: center; - gap: 8px; - margin-top: 8px; - padding: 6px 10px; - background: #f5f5f5; - border: 1px solid #e0e0e0; - border-radius: 6px; - flex-wrap: wrap; -} - -.shareable-link-label { - font-size: 12px; - color: #666; - white-space: nowrap; -} - -.shareable-link-url { - font-size: 11px; - color: #2c3e50; - word-break: break-all; - flex: 1; -} - -.shareable-link-copy { - font-size: 11px; - padding: 2px 8px; - border: 1px solid #bbb; - border-radius: 4px; - background: white; - cursor: pointer; - white-space: nowrap; - color: #444; -} - -.shareable-link-copy:hover { - background: #e8e8e8; -} - -.repo-url-hint { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 15px; - height: 15px; - margin-left: 6px; - border-radius: 50%; - background: #adb5bd; - color: #fff; - font-size: 10px; - font-weight: 700; - font-style: normal; - cursor: default; - vertical-align: middle; - user-select: none; -} - -.repo-url-tooltip { - display: none; - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - width: 260px; - padding: 8px 12px; - background: #2c3e50; - color: #fff; - font-size: 12px; - font-weight: 400; - border-radius: 6px; - line-height: 1.5; - white-space: normal; - z-index: 100; - pointer-events: none; -} - -.repo-url-tooltip::after { - content: ''; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - border: 5px solid transparent; - border-top-color: #2c3e50; -} - -.repo-url-hint:hover .repo-url-tooltip { - display: block; -} - -/* ========== Repo Picker (GitHub App) ========== */ - -.repo-picker { - margin-bottom: 14px; - position: relative; -} - -.repo-picker h6 { - color: #2e3440; - font-size: 1rem; - margin-bottom: 10px; - font-weight: 600; -} - -.repo-picker-trigger { - display: flex; - align-items: center; - padding: 9px 12px; - border: 1px solid #e5e9f0; - border-radius: 8px; - cursor: pointer; - background: #f8f9fc; - transition: border-color 0.2s, border-radius 0.15s; -} - -.repo-picker-trigger:hover { border-color: #2c3e50; } - -.repo-picker-trigger.open { - border-color: #2c3e50; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom-color: transparent; -} - -.repo-picker-trigger-text { - flex: 1; - font-size: 13px; - color: #2c3e50; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.repo-picker-trigger-placeholder { - flex: 1; - font-size: 13px; - color: #adb5bd; -} - -.repo-picker-chevron { - font-size: 11px; - color: #6c757d; - margin-left: 8px; - transition: transform 0.2s; -} - -.repo-picker-trigger.open .repo-picker-chevron { transform: rotate(180deg); } - -.repo-picker-dropdown { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 10; - border: 1px solid #2c3e50; - border-top: none; - border-radius: 0 0 8px 8px; - max-height: 210px; - overflow: hidden; - display: flex; - flex-direction: column; - background: #f8f9fc; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} - -.repo-picker-filter { - padding: 8px 12px; - border: none; - border-bottom: 1px solid #e5e9f0; - outline: none; - font-size: 13px; - color: #2c3e50; - background: transparent; - box-sizing: border-box; - width: 100%; -} - -.repo-picker-filter::placeholder { color: #adb5bd; } - -.repo-picker-list { - overflow-y: auto; - flex: 1; -} - -.repo-picker-item { - padding: 7px 12px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: space-between; - font-size: 13px; - color: #2c3e50; - transition: background 0.15s; -} - -.repo-picker-item:hover { background: #eceff4; } -.repo-picker-item.selected { background: #e0e6ee; } - -.repo-picker-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; -} - -.repo-picker-private { - font-size: 11px; - color: #6c757d; - margin-left: 8px; - flex-shrink: 0; -} - -.repo-picker-empty { - padding: 10px 12px; - color: #6c757d; - font-size: 13px; -} - -.repo-picker-footer { - padding: 7px 12px; - border-top: 1px solid #e5e9f0; - font-size: 12px; - color: #4c566a; - text-decoration: none; - display: block; -} - -.repo-picker-footer:hover { text-decoration: underline; } - -/* ========== GitHub App Install Prompt ========== */ - -.github-app-prompt { - display: inline-flex; - align-items: center; - gap: 6px; - margin-top: 10px; - font-size: 13px; - color: #6c757d; - text-decoration: none; -} - -.github-app-prompt:hover { - color: #2d6a27; - text-decoration: underline; -} - -.github-app-prompt svg { flex-shrink: 0; } - -/* ========== Dark Mode — Repo Picker ========== */ -[data-bs-theme="dark"] .repo-picker h6 { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .repo-picker-trigger { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .repo-picker-trigger:hover { border-color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .repo-picker-trigger.open { border-color: var(--dark-text-secondary) !important; border-bottom-color: transparent !important; } -[data-bs-theme="dark"] .repo-picker-trigger-text { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .repo-picker-trigger-placeholder { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-chevron { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-dropdown { background: var(--dark-bg-tertiary) !important; border-color: var(--dark-text-secondary) !important; } -[data-bs-theme="dark"] .repo-picker-filter { color: var(--dark-text-primary) !important; border-bottom-color: var(--dark-border) !important; } -[data-bs-theme="dark"] .repo-picker-filter::placeholder { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-item { color: var(--dark-text-primary) !important; } -[data-bs-theme="dark"] .repo-picker-item:hover { background: #3d444b !important; } -[data-bs-theme="dark"] .repo-picker-item.selected { background: #3a4550 !important; } -[data-bs-theme="dark"] .repo-picker-private { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-empty { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .repo-picker-footer { color: var(--dark-text-secondary) !important; border-top-color: var(--dark-border) !important; } - -/* ========== Dark Mode — GitHub App Prompt ========== */ -[data-bs-theme="dark"] .github-app-prompt { color: var(--dark-text-muted) !important; } -[data-bs-theme="dark"] .github-app-prompt:hover { color: #7ec87a !important; } diff --git a/runtime/hub/frontend/package.json b/runtime/hub/frontend/package.json index fd809f4..a6ce303 100644 --- a/runtime/hub/frontend/package.json +++ b/runtime/hub/frontend/package.json @@ -7,9 +7,11 @@ "dev": "pnpm -r --parallel run dev", "dev:admin": "pnpm --filter @auplc/admin run dev", "dev:spawn": "pnpm --filter @auplc/spawn run dev", + "dev:home": "pnpm --filter @auplc/home run dev", "build": "pnpm -r run build", "build:admin": "pnpm --filter @auplc/admin run build", "build:spawn": "pnpm --filter @auplc/spawn run build", + "build:home": "pnpm --filter @auplc/home run build", "lint": "pnpm -r run lint", "clean": "pnpm -r exec rm -rf dist node_modules" }, diff --git a/runtime/hub/frontend/pnpm-lock.yaml b/runtime/hub/frontend/pnpm-lock.yaml index c4e9aab..ce3c710 100644 --- a/runtime/hub/frontend/pnpm-lock.yaml +++ b/runtime/hub/frontend/pnpm-lock.yaml @@ -132,6 +132,55 @@ importers: specifier: 'catalog:' version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) + apps/home: + dependencies: + '@auplc/shared': + specifier: workspace:* + version: link:../../packages/shared + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + devDependencies: + '@eslint/js': + specifier: 'catalog:' + version: 9.39.2 + '@types/node': + specifier: 'catalog:' + version: 25.2.3 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 5.1.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)) + eslint: + specifier: 'catalog:' + version: 9.39.2(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: 'catalog:' + version: 5.2.0(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: 'catalog:' + version: 0.4.26(eslint@9.39.2(jiti@2.6.1)) + globals: + specifier: 'catalog:' + version: 16.5.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + typescript-eslint: + specifier: 'catalog:' + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: 'catalog:' + version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0) + apps/spawn: dependencies: '@auplc/shared': diff --git a/runtime/hub/frontend/templates/home.html b/runtime/hub/frontend/templates/home.html index 8a0943b..e93f4a1 100644 --- a/runtime/hub/frontend/templates/home.html +++ b/runtime/hub/frontend/templates/home.html @@ -2,93 +2,30 @@ {% if announcement_home is string %} {% set announcement = announcement_home %} {% endif %} + +{%- block title -%}Home - AUP Learning Cloud{%- endblock title -%} + +{% block stylesheet %} +{{ super() }} + +{% endblock %} + {% block main %} -
-

JupyterHub home page

- - {% if allow_named_servers %} -

Named Servers

-

- In addition to your default server, - you may have additional - {% if named_server_limit_per_user > 0 %}{{ named_server_limit_per_user }}{% endif %} - server(s) with names. - This allows you to have more than one server running at the same time. -

- {% set named_spawners = user.all_spawners(include_default=False)|list %} - - - - - - - - - - - - - - {% for spawner in named_spawners %} - - {# name #} - - {# url #} - - {# activity #} - - {# actions #} - - - {% endfor %} - -
Server nameURLLast activityActions
-
- - -
-
{{ spawner.name }} - {{ user.server_url(spawner.name) }} - - {% if spawner.last_activity %} - {{ spawner.last_activity.isoformat() + 'Z' }} - {% else %} - Never - {% endif %} - - stop - start - -
- {% endif %} -
-{% endblock main %} +
+ + +{% endblock %} + {% block script %} - {{ super() }} - -{% endblock script %} +{{ super() }} +{% endblock %} diff --git a/runtime/values.yaml b/runtime/values.yaml index 8cb99fc..d8d6efb 100644 --- a/runtime/values.yaml +++ b/runtime/values.yaml @@ -343,23 +343,14 @@ hub: # If you want to edit the notice on login.html, change here. mountPath: /usr/local/share/jupyterhub/static/announcement.txt stringData: | -
-

Welcome to AUP Learning Cloud!

-

This is a dynamic announcement.

-

My location is on runtime/values.yaml

-

You can edit this via ConfigMap without rebuilding the image.

-
+ Hello, World! config: # ---- Hub Core ---- - # JupyterHub: - # Enable JupyterHub REST API - # API Documentation: https://jupyterhub.readthedocs.io/en/stable/reference/rest-api.html - # api_tokens: - # Configure API tokens for external services - # Format: token: username - # It's recommended to use environment variables or secrets to manage tokens - # Example: "your-secure-api-token-here": "admin-service" + JupyterHub: + # Always show Home page first, even when a server is already running. + # Users can access their running server via the "My Server" button on Home. + redirect_to_server: false Authenticator: allow_all: true