feat(studio): add dashboard studio UI shell

parent a8ee4e78
......@@ -234,6 +234,27 @@ async def dashboard_providers(request: Request):
}
)
@router.get("/dashboard/studio", response_class=HTMLResponse)
async def dashboard_studio(request: Request):
"""Dashboard Studio shell page."""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
return _templates.TemplateResponse(
request=request,
name="dashboard/studio.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"studio_bootstrap_json": json.dumps({}),
"studio_body_mode": "wide",
},
)
async def _auto_detect_provider_models(provider_key: str, provider: dict) -> list:
"""
Auto-detect models from a provider's API endpoint.
......
.studio-shell {
--studio-border: color-mix(in srgb, var(--color-border) 70%, transparent);
--studio-accent-soft: color-mix(in srgb, var(--color-primary) 18%, var(--bg-panel));
--studio-panel-bg: linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 92%, white 8%), color-mix(in srgb, var(--bg-page) 82%, var(--bg-panel)));
display: grid;
gap: 24px;
}
.studio-hero {
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(260px, 1fr);
gap: 20px;
align-items: stretch;
}
.studio-eyebrow {
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.75rem;
color: var(--color-link);
margin-bottom: 12px;
}
.studio-title {
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1;
margin-bottom: 14px;
}
.studio-subtitle,
.studio-copy {
color: var(--color-muted);
line-height: 1.6;
}
.studio-status-card,
.studio-panel {
background: var(--studio-panel-bg);
border: 1px solid var(--studio-border);
border-radius: 20px;
box-shadow: 0 20px 45px color-mix(in srgb, var(--shadow-lg) 70%, transparent);
}
.studio-status-card {
padding: 24px;
display: grid;
align-content: space-between;
gap: 16px;
}
.studio-status-label,
.studio-chip {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.studio-status-label {
color: var(--color-subtle);
}
.studio-status-value {
font-size: 1.1rem;
line-height: 1.4;
}
.studio-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
gap: 20px;
}
.studio-panel {
padding: 24px;
}
.studio-panel-emphasis {
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--color-link) 20%, transparent), transparent 35%),
var(--studio-panel-bg);
}
.studio-panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 18px;
}
.studio-chip {
padding: 6px 10px;
border-radius: 999px;
background: var(--studio-accent-soft);
color: var(--color-text);
}
.studio-placeholder-list {
display: grid;
gap: 12px;
margin-top: 20px;
}
.studio-placeholder-item {
height: 72px;
border-radius: 16px;
background:
linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-link) 18%, transparent), transparent),
color-mix(in srgb, var(--bg-input) 86%, var(--bg-panel));
background-size: 220% 100%;
animation: studio-sheen 2.8s linear infinite;
border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent);
}
.studio-diagnostics {
margin-top: 20px;
min-height: 164px;
padding: 18px;
border-radius: 16px;
background: color-mix(in srgb, var(--bg-input) 86%, var(--bg-panel));
border: 1px dashed color-mix(in srgb, var(--color-border) 70%, transparent);
color: var(--color-subtle);
}
@keyframes studio-sheen {
0% { background-position: 200% 0; }
100% { background-position: -20% 0; }
}
@media (max-width: 980px) {
.studio-hero,
.studio-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.studio-shell {
gap: 16px;
}
.studio-panel,
.studio-status-card {
padding: 18px;
border-radius: 16px;
}
.studio-panel-header {
align-items: flex-start;
flex-direction: column;
}
}
(function () {
var bootstrapEl = document.getElementById("studio-bootstrap");
var diagnosticsEl = document.getElementById("studio-diagnostics");
if (!bootstrapEl || !diagnosticsEl) {
return;
}
var payload = {};
try {
payload = JSON.parse(bootstrapEl.textContent || "{}");
} catch (error) {
diagnosticsEl.textContent = "Failed to parse Studio bootstrap payload.";
diagnosticsEl.dataset.state = "error";
return;
}
var targets = Array.isArray(payload.targets) ? payload.targets.length : 0;
diagnosticsEl.dataset.state = targets > 0 ? "ready" : "empty";
if (targets > 0) {
diagnosticsEl.textContent = "Studio bootstrap payload loaded.";
}
})();
......@@ -11,6 +11,7 @@
"nav": {
"overview": "Overview",
"providers": "Providers",
"studio": "Studio",
"rotations": "Rotations",
"autoselect": "Autoselect",
"prompts": "Prompts",
......@@ -106,6 +107,20 @@
"yes": "Yes",
"no": "No"
},
"studio": {
"eyebrow": "Workspace",
"title": "Studio",
"subtitle": "Prepare providers, rotations, and autoselect targets from one dashboard-native workspace.",
"status_label": "Status",
"status_placeholder": "Catalog wiring arrives in the next task.",
"catalog_title": "Catalog",
"catalog_scope": "Dashboard shell",
"catalog_description": "This page reserves the full Studio workspace and navigation entry while backend catalog data is still under construction.",
"workspace_title": "Workspace",
"workspace_mode": "UI shell only",
"workspace_description": "Frontend hooks, theme mapping, and bootstrap wiring are in place for the upcoming catalog endpoints.",
"diagnostics_empty": "No diagnostics yet."
},
"providers": {
"no_providers": "No providers found.",
"copy": "Copy",
......@@ -1223,4 +1238,4 @@
"lbl_pending": "Pending Payments",
"lbl_failed": "Failed Payments"
}
}
\ No newline at end of file
}
......@@ -130,6 +130,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: var(--bg-page); color: var(--color-text); }
.container { max-width: 1452px; margin: 0 auto; padding: 20px; }
.container-wide { max-width: min(1680px, calc(100vw - 32px)); }
.header { background: var(--bg-panel); color: var(--color-text); padding: 20px 0; margin-bottom: 30px; border-bottom: 2px solid var(--color-border); }
.header .container { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
.header h1 { font-size: 24px; font-weight: 600; }
......@@ -139,6 +140,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
.nav a:hover { background: var(--bg-accent); color: var(--color-text); }
.nav a.active { background: var(--color-primary); color: white; }
.content { background: var(--bg-panel); padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px var(--shadow); }
.content-wide { padding: 0; background: transparent; box-shadow: none; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: var(--color-text); }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px; border: 1px solid var(--color-border); border-radius: 4px; font-size: 14px; background: var(--bg-input); color: var(--color-text); }
......@@ -829,6 +831,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="nav">
<a href="{{ url_for(request, '/dashboard') }}" {% if request.path == '/dashboard' %}class="active"{% endif %} data-i18n="nav.overview">Overview</a>
<a href="{{ url_for(request, '/dashboard/providers') }}" {% if '/providers' in request.path %}class="active"{% endif %} data-i18n="nav.providers">Providers</a>
<a href="{{ url_for(request, '/dashboard/studio') }}" {% if '/studio' in request.path %}class="active"{% endif %} data-i18n="nav.studio">Studio</a>
<a href="{{ url_for(request, '/dashboard/rotations') }}" {% if '/rotations' in request.path %}class="active"{% endif %} data-i18n="nav.rotations">Rotations</a>
<a href="{{ url_for(request, '/dashboard/autoselect') }}" {% if '/autoselect' in request.path %}class="active"{% endif %} data-i18n="nav.autoselect">Autoselect</a>
<a href="{{ url_for(request, '/dashboard/prompts') }}" {% if '/prompts' in request.path %}class="active"{% endif %} data-i18n="nav.prompts">Prompts</a>
......@@ -892,8 +895,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
{% endif %}
<div class="container">
<div class="content">
<div class="container{% if studio_body_mode == 'wide' %} container-wide{% endif %}">
<div class="content{% if studio_body_mode == 'wide' %} content-wide{% endif %}">
{% block content %}{% endblock %}
</div>
</div>
......
{% extends "base.html" %}
{% block title %}Studio - AISBF Dashboard{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for(request, '/dashboard/static/dashboard/studio.css') }}">
{% endblock %}
{% block content %}
<section class="studio-shell" data-studio-shell="dashboard">
<div class="studio-hero">
<div>
<p class="studio-eyebrow" data-i18n="studio.eyebrow">Workspace</p>
<h2 class="studio-title" data-i18n="studio.title">Studio</h2>
<p class="studio-subtitle" data-i18n="studio.subtitle">Prepare providers, rotations, and autoselect targets from one dashboard-native workspace.</p>
</div>
<div class="studio-status-card">
<span class="studio-status-label" data-i18n="studio.status_label">Status</span>
<strong class="studio-status-value" data-i18n="studio.status_placeholder">Catalog wiring arrives in the next task.</strong>
</div>
</div>
<div class="studio-grid">
<section class="studio-panel">
<header class="studio-panel-header">
<h3 data-i18n="studio.catalog_title">Catalog</h3>
<span class="studio-chip" data-i18n="studio.catalog_scope">Dashboard shell</span>
</header>
<p class="studio-copy" data-i18n="studio.catalog_description">This page reserves the full Studio workspace and navigation entry while backend catalog data is still under construction.</p>
<div class="studio-placeholder-list" aria-hidden="true">
<div class="studio-placeholder-item"></div>
<div class="studio-placeholder-item"></div>
<div class="studio-placeholder-item"></div>
</div>
</section>
<section class="studio-panel studio-panel-emphasis">
<header class="studio-panel-header">
<h3 data-i18n="studio.workspace_title">Workspace</h3>
<span class="studio-chip" data-i18n="studio.workspace_mode">UI shell only</span>
</header>
<p class="studio-copy" data-i18n="studio.workspace_description">Frontend hooks, theme mapping, and bootstrap wiring are in place for the upcoming catalog endpoints.</p>
<div class="studio-diagnostics" id="studio-diagnostics" data-empty-message="No diagnostics yet.">
<span data-i18n="studio.diagnostics_empty">No diagnostics yet.</span>
</div>
</section>
</div>
</section>
<script id="studio-bootstrap" type="application/json">{{ studio_bootstrap_json|safe }}</script>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for(request, '/dashboard/static/dashboard/studio.js') }}"></script>
{% endblock %}
import json
from pathlib import Path
import sys
from base64 import b64encode
from fastapi.testclient import TestClient
from itsdangerous import TimestampSigner
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from main import app
from aisbf.routes.dashboard import providers as dashboard_providers
from main import templates
def _set_session_cookie(client: TestClient, data: dict) -> None:
signer = TimestampSigner(app.user_middleware[2].kwargs["secret_key"])
serialized = b64encode(json.dumps(data).encode("utf-8"))
signed = signer.sign(serialized).decode("utf-8")
client.cookies.set("session", signed)
dashboard_providers.init(None, templates, None)
def _login_as_admin(client: TestClient) -> None:
_set_session_cookie(
client,
{
"logged_in": True,
"username": "admin",
"role": "admin",
"user_id": None,
"expires_at": 4102444800,
},
)
def test_dashboard_studio_redirects_when_logged_out():
client = TestClient(app)
response = client.get("/dashboard/studio", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"].endswith("/dashboard/login")
def test_dashboard_studio_page_is_available_for_admin_with_active_nav():
client = TestClient(app)
_login_as_admin(client)
response = client.get("/dashboard/studio")
assert response.status_code == 200
assert 'data-i18n="nav.studio">Studio</a>' in response.text
assert 'class="container container-wide"' in response.text
assert 'class="content content-wide"' in response.text
assert 'data-studio-shell="dashboard"' in response.text
def test_dashboard_studio_nav_entry_is_present_for_user_session_shell():
client = TestClient(app)
_set_session_cookie(
client,
{
"logged_in": True,
"username": "demo",
"role": "user",
"user_id": None,
"expires_at": 4102444800,
},
)
response = client.get("/dashboard/studio")
assert response.status_code == 200
assert 'data-i18n="nav.studio">Studio</a>' in response.text
assert 'data-studio-shell="dashboard"' in response.text
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment