updated sidebar

This commit is contained in:
buenosairesam
2026-01-27 00:06:29 -03:00
parent cae5a913ca
commit fecb978a5f
5 changed files with 842 additions and 714 deletions

View File

@@ -179,10 +179,20 @@ def build_managed(output_dir: Path, cfg_name: str, config: dict):
): ):
copy_path(item, managed_dir / item.name) copy_path(item, managed_dir / item.name)
# Scripts from ctrl/ -> managed/ctrl/ # Copy managed app config from cfg/<room>/<managed_name>/ (e.g., .env, dumps/)
room_managed_cfg = room_cfg / managed_name
if room_managed_cfg.exists():
log.info(f" Copying {managed_name} config...")
for item in room_managed_cfg.iterdir():
if item.is_file():
copy_path(item, managed_dir / item.name, quiet=True)
elif item.is_dir():
copy_path(item, managed_dir / item.name)
# Scripts from ctrl/ -> output_dir/ctrl/ (sibling of managed, link, soleprint)
room_ctrl = room_cfg / "ctrl" room_ctrl = room_cfg / "ctrl"
if room_ctrl.exists(): if room_ctrl.exists():
ctrl_dir = managed_dir / "ctrl" ctrl_dir = output_dir / "ctrl"
ensure_dir(ctrl_dir) ensure_dir(ctrl_dir)
for item in room_ctrl.iterdir(): for item in room_ctrl.iterdir():
if item.is_file(): if item.is_file():
@@ -307,13 +317,19 @@ def build_soleprint(output_dir: Path, room: str):
log.warning("Model generation failed") log.warning("Model generation failed")
def build(output_dir: Path, cfg_name: str | None = None): def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True):
"""Build complete room instance.""" """Build complete room instance."""
room = cfg_name or "standalone" room = cfg_name or "standalone"
config = load_config(cfg_name) config = load_config(cfg_name)
managed = config.get("managed") managed = config.get("managed")
log.info(f"\n=== Building {room} ===") log.info(f"\n=== Building {room} ===")
# Clean output directory first
if clean and output_dir.exists():
log.info(f"Cleaning {output_dir}...")
shutil.rmtree(output_dir)
ensure_dir(output_dir) ensure_dir(output_dir)
if managed: if managed:

View File

@@ -1,174 +1,426 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>soleprint</title> <title>soleprint</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E"> <link
<style> rel="icon"
* { box-sizing: border-box; } type="image/svg+xml"
html { background: #0a0a0a; } href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E"
body { />
font-family: system-ui, -apple-system, sans-serif; <style>
max-width: 960px; * {
margin: 0 auto; box-sizing: border-box;
padding: 2rem 1rem; }
line-height: 1.6; html {
color: #e5e5e5; background: #0a0a0a;
background: #0a0a0a; }
} body {
header { font-family:
display: flex; system-ui,
align-items: center; -apple-system,
gap: 1rem; sans-serif;
margin-bottom: 1rem; max-width: 960px;
} margin: 0 auto;
.logo { width: 64px; height: 64px; } padding: 2rem 1rem;
h1 { font-size: 2.5rem; margin: 0; color: white; } line-height: 1.6;
.tagline { color: #e5e5e5;
color: #a3a3a3; background: #0a0a0a;
margin-bottom: 2rem; }
border-bottom: 1px solid #333; /* Sidebar styles */
padding-bottom: 2rem; .sidebar {
} position: fixed;
.mission { top: 0;
background: #1a1a1a; left: 0;
border-left: 3px solid #d4a574; width: 60px;
padding: 1rem 1.5rem; height: 100vh;
margin: 2rem 0; background: #1a1a1a;
border-radius: 0 8px 8px 0; border-right: 1px solid #333;
color: #d4a574; display: flex;
} flex-direction: column;
.systems { align-items: center;
display: grid; padding: 1rem 0;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); z-index: 1000;
gap: 1.5rem; }
margin: 2rem 0; .sidebar-item {
} width: 44px;
.system { height: 44px;
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 1rem; justify-content: center;
text-decoration: none; border-radius: 8px;
padding: 1.5rem; margin-bottom: 0.5rem;
border-radius: 12px; text-decoration: none;
transition: transform 0.15s, box-shadow 0.15s; color: #a3a3a3;
} transition: all 0.2s;
.system:hover { }
transform: translateY(-2px); .sidebar-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1); background: #333;
} color: white;
.system.disabled { }
opacity: 0.5; .sidebar-item.active {
pointer-events: none; background: #d4a574;
} color: #0a0a0a;
.system svg { width: 48px; height: 48px; flex-shrink: 0; } }
.system-info h2 { margin: 0 0 0.25rem 0; font-size: 1.2rem; } .sidebar-item svg {
.system-info p { margin: 0; font-size: 0.9rem; color: #a3a3a3; } width: 24px;
height: 24px;
}
.sidebar-divider {
width: 32px;
height: 1px;
background: #333;
margin: 0.5rem 0;
}
.sidebar-item .tooltip {
position: absolute;
left: 70px;
background: #333;
color: white;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.sidebar-item:hover .tooltip {
opacity: 1;
}
body.has-sidebar {
margin-left: 60px;
}
header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.logo {
width: 64px;
height: 64px;
}
h1 {
font-size: 2.5rem;
margin: 0;
color: white;
}
.tagline {
color: #a3a3a3;
margin-bottom: 2rem;
border-bottom: 1px solid #333;
padding-bottom: 2rem;
}
.mission {
background: #1a1a1a;
border-left: 3px solid #d4a574;
padding: 1rem 1.5rem;
margin: 2rem 0;
border-radius: 0 8px 8px 0;
color: #d4a574;
}
.systems {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.system {
display: flex;
align-items: flex-start;
gap: 1rem;
text-decoration: none;
padding: 1.5rem;
border-radius: 12px;
transition:
transform 0.15s,
box-shadow 0.15s;
}
.system:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.system.disabled {
opacity: 0.5;
pointer-events: none;
}
.system svg {
width: 48px;
height: 48px;
flex-shrink: 0;
}
.system-info h2 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
}
.system-info p {
margin: 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.artery { background: #1a1a1a; border: 1px solid #b91c1c; } .artery {
.artery h2 { color: #fca5a5; } background: #1a1a1a;
.artery svg { color: #b91c1c; } border: 1px solid #b91c1c;
}
.artery h2 {
color: #fca5a5;
}
.artery svg {
color: #b91c1c;
}
.atlas { background: #1a1a1a; border: 1px solid #15803d; } .atlas {
.atlas h2 { color: #86efac; } background: #1a1a1a;
.atlas svg { color: #15803d; } border: 1px solid #15803d;
}
.atlas h2 {
color: #86efac;
}
.atlas svg {
color: #15803d;
}
.station { background: #1a1a1a; border: 1px solid #1d4ed8; } .station {
.station h2 { color: #93c5fd; } background: #1a1a1a;
.station svg { color: #1d4ed8; } border: 1px solid #1d4ed8;
}
.station h2 {
color: #93c5fd;
}
.station svg {
color: #1d4ed8;
}
footer { footer {
margin-top: 3rem; margin-top: 3rem;
padding-top: 1.5rem; padding-top: 1.5rem;
border-top: 1px solid #333; border-top: 1px solid #333;
font-size: 0.85rem; font-size: 0.85rem;
color: #666; color: #666;
} }
</style> </style>
</head> </head>
<body> <body{% if managed %} class="has-sidebar"{% endif %}>
<header> {% if managed %}
<!-- Two shoe prints walking --> <nav class="sidebar">
<svg class="logo" viewBox="0 0 64 64" fill="currentColor"> {% if managed_url %}
<!-- Left shoe print (back, lower) --> <a href="{{ managed_url }}" class="sidebar-item" title="{{ managed.name }}">
<g transform="rotate(-15 18 38)"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<!-- Sole --> <rect x="3" y="3" width="18" height="18" rx="2"/>
<ellipse cx="18" cy="32" rx="7" ry="13"/> <path d="M9 3v18M3 9h6"/>
<!-- Heel --> </svg>
<ellipse cx="18" cy="48" rx="6" ry="7"/> <span class="tooltip">{{ managed.name }}</span>
</g> </a>
<!-- Right shoe print (front, higher) --> <div class="sidebar-divider"></div>
<g transform="rotate(15 46 28)"> {% endif %}
<!-- Sole -->
<ellipse cx="46" cy="22" rx="7" ry="13"/>
<!-- Heel -->
<ellipse cx="46" cy="38" rx="6" ry="7"/>
</g>
</svg>
<h1>soleprint</h1>
</header>
<p class="tagline">Cada paso deja huella</p>
<p class="mission" style="display:none;"><!-- placeholder for session alerts --></p> <a href="/" class="sidebar-item active" title="Soleprint">
<svg viewBox="0 0 24 24" fill="currentColor">
<ellipse cx="8" cy="10" rx="3" ry="5" transform="rotate(-10 8 10)"/>
<ellipse cx="8" cy="17" rx="2.5" ry="3" transform="rotate(-10 8 17)"/>
<ellipse cx="16" cy="8" rx="3" ry="5" transform="rotate(10 16 8)"/>
<ellipse cx="16" cy="15" rx="2.5" ry="3" transform="rotate(10 16 15)"/>
</svg>
<span class="tooltip">Soleprint</span>
</a>
<div class="systems"> <a href="/artery" class="sidebar-item" title="Artery">
<a {% if artery %}href="{{ artery }}"{% endif %} class="system artery{% if not artery %} disabled{% endif %}"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<!-- Flux capacitor style --> <path d="M12 2v8M12 10L6 18M12 10l6 8"/>
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.5"> <circle cx="12" cy="10" r="2"/>
<path d="M24 4 L24 20 M24 20 L8 40 M24 20 L40 40"/> </svg>
<circle cx="24" cy="4" r="3" fill="currentColor"/> <span class="tooltip">Artery</span>
<circle cx="8" cy="40" r="3" fill="currentColor"/> </a>
<circle cx="40" cy="40" r="3" fill="currentColor"/>
<circle cx="24" cy="20" r="5" fill="none"/> <a href="/atlas" class="sidebar-item" title="Atlas">
<circle cx="24" cy="20" r="2" fill="currentColor"/> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="8"/>
<path d="M12 4v16M4 12h16"/>
</svg>
<span class="tooltip">Atlas</span>
</a>
<a href="/station" class="sidebar-item" title="Station">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="6" width="16" height="12" rx="1"/>
<circle cx="9" cy="11" r="2"/>
<circle cx="15" cy="11" r="2"/>
</svg>
<span class="tooltip">Station</span>
</a>
</nav>
{% endif %}
<header>
<!-- Two shoe prints walking -->
<svg class="logo" viewBox="0 0 64 64" fill="currentColor">
<!-- Left shoe print (back, lower) -->
<g transform="rotate(-15 18 38)">
<!-- Sole -->
<ellipse cx="18" cy="32" rx="7" ry="13" />
<!-- Heel -->
<ellipse cx="18" cy="48" rx="6" ry="7" />
</g>
<!-- Right shoe print (front, higher) -->
<g transform="rotate(15 46 28)">
<!-- Sole -->
<ellipse cx="46" cy="22" rx="7" ry="13" />
<!-- Heel -->
<ellipse cx="46" cy="38" rx="6" ry="7" />
</g>
</svg> </svg>
<div class="system-info"> <h1>soleprint</h1>
<h2>Artery</h2> </header>
<p>Todo lo vital</p> <p class="tagline">Cada paso deja huella</p>
</div>
</a>
<a {% if atlas %}href="{{ atlas }}"{% endif %} class="system atlas{% if not atlas %} disabled{% endif %}"> <p class="mission" style="display: none">
<!-- Map/Atlas with compass rose --> <!-- placeholder for session alerts -->
<svg viewBox="0 0 48 48" fill="currentColor"> </p>
<!-- Map fold lines -->
<path d="M4 8 L44 8 M4 16 L44 16 M4 24 L44 24 M4 32 L44 32 M4 40 L44 40" stroke="currentColor" stroke-width="1.5" opacity="0.3" fill="none"/>
<path d="M16 4 L16 44 M32 4 L32 44" stroke="currentColor" stroke-width="1.5" opacity="0.3" fill="none"/>
<!-- Compass rose in center -->
<circle cx="24" cy="24" r="8" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M24 16 L24 32 M16 24 L32 24" stroke="currentColor" stroke-width="2"/>
<path d="M24 16 L26 20 L24 24 L22 20 Z" fill="currentColor"/><!-- North arrow -->
</svg>
<div class="system-info">
<h2>Atlas</h2>
<p>Documentación accionable</p>
</div>
</a>
<a {% if station %}href="{{ station }}"{% endif %} class="system station{% if not station %} disabled{% endif %}"> <div class="systems">
<!-- Control panel with knobs and meters --> <a
<svg viewBox="0 0 48 48" fill="currentColor"> {%
<!-- Panel frame --> if
<rect x="4" y="8" width="40" height="32" rx="2" fill="none" stroke="currentColor" stroke-width="2"/> artery
<!-- Knobs --> %}href="{{ artery }}"
<circle cx="14" cy="18" r="5"/> {%
<circle cx="14" cy="18" r="2" fill="white"/> endif
<circle cx="34" cy="18" r="5"/> %}
<circle cx="34" cy="18" r="2" fill="white"/> class="system artery{% if not artery %} disabled{% endif %}"
<!-- Meter displays --> >
<rect x="10" y="28" width="8" height="6" rx="1" fill="white" opacity="0.6"/> <!-- Flux capacitor style -->
<rect x="30" y="28" width="8" height="6" rx="1" fill="white" opacity="0.6"/> <svg
<!-- Indicator lights --> viewBox="0 0 48 48"
<circle cx="24" cy="14" r="2" fill="white" opacity="0.8"/> fill="none"
</svg> stroke="currentColor"
<div class="system-info"> stroke-width="2.5"
<h2>Station</h2> >
<p>Monitores, Entornos y Herramientas</p> <path d="M24 4 L24 20 M24 20 L8 40 M24 20 L40 40" />
</div> <circle cx="24" cy="4" r="3" fill="currentColor" />
</a> <circle cx="8" cy="40" r="3" fill="currentColor" />
</div> <circle cx="40" cy="40" r="3" fill="currentColor" />
<circle cx="24" cy="20" r="5" fill="none" />
<circle cx="24" cy="20" r="2" fill="currentColor" />
</svg>
<div class="system-info">
<h2>Artery</h2>
<p>Todo lo vital</p>
</div>
</a>
<footer>soleprint</footer> <a
</body> {%
if
atlas
%}href="{{ atlas }}"
{%
endif
%}
class="system atlas{% if not atlas %} disabled{% endif %}"
>
<!-- Map/Atlas with compass rose -->
<svg viewBox="0 0 48 48" fill="currentColor">
<!-- Map fold lines -->
<path
d="M4 8 L44 8 M4 16 L44 16 M4 24 L44 24 M4 32 L44 32 M4 40 L44 40"
stroke="currentColor"
stroke-width="1.5"
opacity="0.3"
fill="none"
/>
<path
d="M16 4 L16 44 M32 4 L32 44"
stroke="currentColor"
stroke-width="1.5"
opacity="0.3"
fill="none"
/>
<!-- Compass rose in center -->
<circle
cx="24"
cy="24"
r="8"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M24 16 L24 32 M16 24 L32 24"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M24 16 L26 20 L24 24 L22 20 Z"
fill="currentColor"
/>
<!-- North arrow -->
</svg>
<div class="system-info">
<h2>Atlas</h2>
<p>Documentación accionable</p>
</div>
</a>
<a
{%
if
station
%}href="{{ station }}"
{%
endif
%}
class="system station{% if not station %} disabled{% endif %}"
>
<!-- Control panel with knobs and meters -->
<svg viewBox="0 0 48 48" fill="currentColor">
<!-- Panel frame -->
<rect
x="4"
y="8"
width="40"
height="32"
rx="2"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<!-- Knobs -->
<circle cx="14" cy="18" r="5" />
<circle cx="14" cy="18" r="2" fill="white" />
<circle cx="34" cy="18" r="5" />
<circle cx="34" cy="18" r="2" fill="white" />
<!-- Meter displays -->
<rect
x="10"
y="28"
width="8"
height="6"
rx="1"
fill="white"
opacity="0.6"
/>
<rect
x="30"
y="28"
width="8"
height="6"
rx="1"
fill="white"
opacity="0.6"
/>
<!-- Indicator lights -->
<circle cx="24" cy="14" r="2" fill="white" opacity="0.8" />
</svg>
<div class="system-info">
<h2>Station</h2>
<p>Monitores, Entornos y Herramientas</p>
</div>
</a>
</div>
<footer>soleprint</footer>
</body>
</html> </html>

View File

@@ -512,20 +512,109 @@ def station_route(path: str):
return {"system": "station", "path": path} return {"system": "station", "path": path}
# === Sidebar Wrapper (served at /spr/* when proxied) ===
@app.get("/sidebar.css")
def sidebar_css():
"""Serve sidebar CSS for injection."""
css_path = SPR_ROOT / "station" / "tools" / "sbwrapper" / "sidebar.css"
if css_path.exists():
from fastapi.responses import Response
return Response(content=css_path.read_text(), media_type="text/css")
return Response(content="/* sidebar.css not found */", media_type="text/css")
@app.get("/sidebar.js")
def sidebar_js():
"""Serve sidebar JS for injection."""
js_path = SPR_ROOT / "station" / "tools" / "sbwrapper" / "sidebar.js"
if js_path.exists():
from fastapi.responses import Response
return Response(
content=js_path.read_text(), media_type="application/javascript"
)
return Response(
content="/* sidebar.js not found */", media_type="application/javascript"
)
@app.get("/api/sidebar/config")
def sidebar_config(request: Request):
"""Return sidebar configuration for the current room."""
config = load_config()
managed = config.get("managed", {})
auth = config.get("auth", {})
# Get soleprint URL (where tools are)
host = request.headers.get("host", "localhost")
host_no_port = host.split(":")[0]
scheme = request.headers.get("x-forwarded-proto", "http")
# Soleprint tools are at /spr/* when accessed via <room>.spr.<domain>
soleprint_base = "/spr"
return {
"room": managed.get("name", "standalone"),
"soleprint_base": soleprint_base,
"auth_enabled": auth.get("enabled", False),
"tools": {
"artery": f"{soleprint_base}/artery",
"atlas": f"{soleprint_base}/atlas",
"station": f"{soleprint_base}/station",
},
"auth": {
"login_url": f"{soleprint_base}/artery/google/oauth/login",
"status_url": f"{soleprint_base}/artery/google/oauth/status",
"logout_url": f"{soleprint_base}/artery/google/oauth/logout",
},
}
# === Main === # === Main ===
def get_managed_url(request: Request, managed: dict) -> str | None:
"""
Derive managed app URL from current host.
Pattern: <room>.spr.<domain> -> <room>.<domain>
localhost:port -> None (no managed URL for direct port access)
"""
if not managed:
return None
host = request.headers.get("host", "localhost")
host_no_port = host.split(":")[0]
# Check if host matches pattern: <name>.spr.<domain>
if ".spr." in host_no_port:
# Replace .spr. with . to get managed app domain
managed_host = host_no_port.replace(".spr.", ".")
scheme = request.headers.get("x-forwarded-proto", "http")
return f"{scheme}://{managed_host}"
return None
@app.get("/") @app.get("/")
def index(request: Request): def index(request: Request):
"""Landing page with links to subsystems.""" """Landing page with links to subsystems."""
config = load_config()
managed = config.get("managed", {})
managed_url = get_managed_url(request, managed)
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
{ {
"request": request, "request": request,
# In bare-metal mode, all routes are internal
"artery": "/artery", "artery": "/artery",
"atlas": "/atlas", "atlas": "/atlas",
"station": "/station", "station": "/station",
"managed": managed,
"managed_url": managed_url,
}, },
) )

View File

@@ -1,296 +1,161 @@
/* Pawprint Wrapper - Sidebar Styles */ /* Soleprint Sidebar - Injected Styles */
:root { :root {
--sidebar-width: 320px; --spr-sidebar-width: 60px;
--sidebar-bg: #1e1e1e; --spr-sidebar-width-expanded: 280px;
--sidebar-text: #e0e0e0; --spr-sidebar-bg: #1a1a1a;
--sidebar-accent: #007acc; --spr-sidebar-text: #e5e5e5;
--sidebar-border: #333; --spr-sidebar-text-muted: #a3a3a3;
--sidebar-shadow: 0 0 20px rgba(0,0,0,0.5); --spr-sidebar-border: #333;
--card-bg: #2a2a2a; --spr-sidebar-accent: #d4a574;
--card-hover: #3a3a3a; --spr-sidebar-hover: #2a2a2a;
--success: #4caf50;
--error: #f44336;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 0;
} }
/* Sidebar Container */ /* Sidebar Container */
#pawprint-sidebar { #spr-sidebar {
position: fixed; position: fixed;
right: 0; left: 0;
top: 0; top: 0;
width: var(--sidebar-width); width: var(--spr-sidebar-width);
height: 100vh; height: 100vh;
background: var(--sidebar-bg); background: var(--spr-sidebar-bg);
color: var(--sidebar-text); border-right: 1px solid var(--spr-sidebar-border);
box-shadow: var(--sidebar-shadow); display: flex;
transform: translateX(100%); flex-direction: column;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 99999;
z-index: 9999; transition: width 0.2s ease;
overflow-y: auto; font-family:
overflow-x: hidden; system-ui,
display: flex; -apple-system,
flex-direction: column; sans-serif;
} }
#pawprint-sidebar.expanded { #spr-sidebar.expanded {
transform: translateX(0); width: var(--spr-sidebar-width-expanded);
} }
/* Toggle Button */ /* Push page content */
#sidebar-toggle { body.spr-sidebar-active {
position: fixed; margin-left: var(--spr-sidebar-width) !important;
right: 0; transition: margin-left 0.2s ease;
top: 50%;
transform: translateY(-50%);
background: var(--sidebar-bg);
color: var(--sidebar-text);
border: 1px solid var(--sidebar-border);
border-right: none;
border-radius: 8px 0 0 8px;
padding: 12px 8px;
cursor: pointer;
z-index: 10000;
font-size: 16px;
transition: background 0.2s;
box-shadow: -2px 0 8px rgba(0,0,0,0.3);
} }
#sidebar-toggle:hover { body.spr-sidebar-active.spr-sidebar-expanded {
background: var(--card-hover); margin-left: var(--spr-sidebar-width-expanded) !important;
} }
#sidebar-toggle .icon { /* Sidebar Items */
display: block; .spr-sidebar-item {
transition: transform 0.3s; display: flex;
} align-items: center;
gap: 12px;
#pawprint-sidebar.expanded ~ #sidebar-toggle .icon { padding: 12px 18px;
transform: scaleX(-1); color: var(--spr-sidebar-text-muted);
} text-decoration: none;
transition: all 0.15s;
/* Header */ cursor: pointer;
.sidebar-header { border: none;
padding: 20px; background: none;
border-bottom: 1px solid var(--sidebar-border);
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
}
.sidebar-header h2 {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
color: var(--sidebar-accent);
}
.sidebar-header .nest-name {
font-size: 12px;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Content */
.sidebar-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
/* Panel */
.panel {
margin-bottom: 24px;
padding: 16px;
background: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--sidebar-border);
}
.panel h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--sidebar-accent);
display: flex;
align-items: center;
gap: 8px;
}
/* Current User Display */
.current-user {
padding: 12px;
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 6px;
margin-bottom: 16px;
font-size: 13px;
}
.current-user strong {
color: var(--success);
font-weight: 600;
}
.current-user .logout-btn {
margin-top: 8px;
padding: 6px 12px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
color: var(--error);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
width: 100%;
}
.current-user .logout-btn:hover {
background: rgba(244, 67, 54, 0.2);
}
/* User Cards */
.user-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--card-bg);
border: 1px solid var(--sidebar-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.user-card:hover {
background: var(--card-hover);
border-color: var(--sidebar-accent);
transform: translateX(-2px);
}
.user-card.active {
background: rgba(0, 122, 204, 0.2);
border-color: var(--sidebar-accent);
}
.user-card .icon {
font-size: 24px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.05);
border-radius: 50%;
}
.user-card .info {
flex: 1;
}
.user-card .label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
}
.user-card .role {
display: block;
font-size: 11px;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Status Messages */
.status-message {
padding: 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 16px;
border: 1px solid;
}
.status-message.success {
background: rgba(76, 175, 80, 0.1);
border-color: rgba(76, 175, 80, 0.3);
color: var(--success);
}
.status-message.error {
background: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.3);
color: var(--error);
}
.status-message.info {
background: rgba(0, 122, 204, 0.1);
border-color: rgba(0, 122, 204, 0.3);
color: var(--sidebar-accent);
}
/* Loading Spinner */
.loading {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.1);
border-top-color: var(--sidebar-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Scrollbar */
#pawprint-sidebar::-webkit-scrollbar {
width: 8px;
}
#pawprint-sidebar::-webkit-scrollbar-track {
background: #1a1a1a;
}
#pawprint-sidebar::-webkit-scrollbar-thumb {
background: #444;
border-radius: 4px;
}
#pawprint-sidebar::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Footer */
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--sidebar-border);
font-size: 11px;
opacity: 0.5;
text-align: center;
}
/* Responsive */
@media (max-width: 768px) {
#pawprint-sidebar {
width: 100%; width: 100%;
} text-align: left;
font-size: 14px;
}
.spr-sidebar-item:hover {
background: var(--spr-sidebar-hover);
color: var(--spr-sidebar-text);
}
.spr-sidebar-item.active {
background: var(--spr-sidebar-accent);
color: #0a0a0a;
}
.spr-sidebar-item svg {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.spr-sidebar-item .label {
display: none;
white-space: nowrap;
}
#spr-sidebar.expanded .spr-sidebar-item .label {
display: inline;
}
/* Divider */
.spr-sidebar-divider {
height: 1px;
background: var(--spr-sidebar-border);
margin: 8px 16px;
}
/* Spacer */
.spr-sidebar-spacer {
flex: 1;
}
/* User Section */
.spr-sidebar-user {
padding: 12px 16px;
border-top: 1px solid var(--spr-sidebar-border);
font-size: 12px;
color: var(--spr-sidebar-text-muted);
}
.spr-sidebar-user .email {
display: none;
margin-top: 4px;
color: var(--spr-sidebar-text);
}
#spr-sidebar.expanded .spr-sidebar-user .email {
display: block;
}
/* Login Button */
.spr-login-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #4285f4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
width: 100%;
justify-content: center;
}
.spr-login-btn:hover {
background: #3367d6;
}
.spr-login-btn svg {
width: 18px;
height: 18px;
}
/* Header with room name */
.spr-sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--spr-sidebar-border);
}
.spr-sidebar-header .room-name {
display: none;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--spr-sidebar-accent);
margin-top: 8px;
}
#spr-sidebar.expanded .spr-sidebar-header .room-name {
display: block;
} }

View File

@@ -1,295 +1,201 @@
// Soleprint Wrapper - Sidebar Logic // Soleprint Sidebar - Self-Injecting Script
// This script creates and manages the soleprint sidebar when injected into a managed app
class SoleprintSidebar { (function () {
constructor() { "use strict";
this.config = null;
this.currentUser = null;
this.sidebar = null;
this.toggleBtn = null;
}
async init() { const SPR_BASE = "/spr";
// Load configuration
await this.loadConfig();
// Create sidebar elements let config = null;
this.createSidebar(); let user = null;
this.createToggleButton(); let expanded = localStorage.getItem("spr_sidebar_expanded") === "true";
// Setup event listeners // Icons as SVG strings
this.setupEventListeners(); const icons = {
toggle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h16M4 12h16M4 18h16"/>
</svg>`,
home: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12l9-9 9 9M5 10v10a1 1 0 001 1h3m10-11v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1"/>
</svg>`,
soleprint: `<svg viewBox="0 0 24 24" fill="currentColor">
<ellipse cx="8" cy="10" rx="3" ry="5" transform="rotate(-10 8 10)"/>
<ellipse cx="8" cy="17" rx="2.5" ry="3" transform="rotate(-10 8 17)"/>
<ellipse cx="16" cy="8" rx="3" ry="5" transform="rotate(10 16 8)"/>
<ellipse cx="16" cy="15" rx="2.5" ry="3" transform="rotate(10 16 15)"/>
</svg>`,
artery: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v8M12 10L6 18M12 10l6 8"/>
<circle cx="12" cy="10" r="2"/>
</svg>`,
atlas: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="8"/>
<path d="M12 4v16M4 12h16"/>
</svg>`,
station: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="6" width="16" height="12" rx="1"/>
<circle cx="9" cy="11" r="2"/>
<circle cx="15" cy="11" r="2"/>
</svg>`,
google: `<svg viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>`,
user: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"/>
</svg>`,
logout: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
</svg>`,
};
// Check if user is already logged in async function init() {
this.checkCurrentUser();
// Load saved sidebar state
this.loadSidebarState();
}
async loadConfig() {
try { try {
const response = await fetch("/wrapper/config.json"); // Load config
this.config = await response.json(); const response = await fetch(`${SPR_BASE}/api/sidebar/config`);
console.log("[Soleprint] Config loaded:", this.config.nest_name); config = await response.json();
} catch (error) {
console.error("[Soleprint] Failed to load config:", error);
// Use default config
this.config = {
nest_name: "default",
wrapper: {
environment: {
backend_url: "http://localhost:8000",
frontend_url: "http://localhost:3000",
},
users: [],
},
};
}
}
createSidebar() { // Check auth status
const sidebar = document.createElement("div"); await checkAuth();
sidebar.id = "pawprint-sidebar";
sidebar.innerHTML = this.getSidebarHTML();
document.body.appendChild(sidebar);
this.sidebar = sidebar;
}
createToggleButton() { // Create sidebar
const button = document.createElement("button"); createSidebar();
button.id = "sidebar-toggle";
button.innerHTML = '<span class="icon">◀</span>';
button.title = "Toggle Soleprint Sidebar (Ctrl+Shift+P)";
document.body.appendChild(button);
this.toggleBtn = button;
}
getSidebarHTML() { // Setup keyboard shortcut
const users = this.config.wrapper.users || []; document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && e.key === "S") {
return ` e.preventDefault();
<div class="sidebar-header"> toggleSidebar();
<h2>🐾 Soleprint</h2> }
<div class="nest-name">${this.config.nest_name}</div>
</div>
<div class="sidebar-content">
<div id="status-container"></div>
<!-- Quick Login Panel -->
<div class="panel">
<h3>👤 Quick Login</h3>
<div id="current-user-display" style="display: none;">
<div class="current-user">
Logged in as: <strong id="current-username"></strong>
<button class="logout-btn" onclick="soleprintSidebar.logout()">
Logout
</button>
</div>
</div>
<div class="user-cards">
${users
.map(
(user) => `
<div class="user-card" data-user-id="${user.id}" onclick="soleprintSidebar.loginAs('${user.id}')">
<div class="icon">${user.icon}</div>
<div class="info">
<span class="label">${user.label}</span>
<span class="role">${user.role}</span>
</div>
</div>
`,
)
.join("")}
</div>
</div>
<!-- Environment Info Panel -->
<div class="panel">
<h3>🌍 Environment</h3>
<div style="font-size: 12px; opacity: 0.8;">
<div style="margin-bottom: 8px;">
<strong>Backend:</strong><br>
<code style="font-size: 11px;">${this.config.wrapper.environment.backend_url}</code>
</div>
<div>
<strong>Frontend:</strong><br>
<code style="font-size: 11px;">${this.config.wrapper.environment.frontend_url}</code>
</div>
</div>
</div>
</div>
<div class="sidebar-footer">
Soleprint Dev Tools
</div>
`;
}
setupEventListeners() {
// Toggle button
this.toggleBtn.addEventListener("click", () => this.toggle());
// Keyboard shortcut: Ctrl+Shift+P
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && e.key === "P") {
e.preventDefault();
this.toggle();
}
});
}
toggle() {
this.sidebar.classList.toggle("expanded");
this.saveSidebarState();
}
saveSidebarState() {
const isExpanded = this.sidebar.classList.contains("expanded");
localStorage.setItem("pawprint_sidebar_expanded", isExpanded);
}
loadSidebarState() {
const isExpanded =
localStorage.getItem("pawprint_sidebar_expanded") === "true";
if (isExpanded) {
this.sidebar.classList.add("expanded");
}
}
showStatus(message, type = "info") {
const container = document.getElementById("status-container");
const statusDiv = document.createElement("div");
statusDiv.className = `status-message ${type}`;
statusDiv.textContent = message;
container.innerHTML = "";
container.appendChild(statusDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
statusDiv.remove();
}, 5000);
}
async loginAs(userId) {
const user = this.config.wrapper.users.find((u) => u.id === userId);
if (!user) return;
this.showStatus(`Logging in as ${user.label}... ⏳`, "info");
try {
const backendUrl = this.config.wrapper.environment.backend_url;
const response = await fetch(`${backendUrl}/api/token/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: user.username,
password: user.password,
}),
}); });
if (!response.ok) { console.log("[Soleprint] Sidebar initialized for room:", config.room);
throw new Error(`Login failed: ${response.status}`); } catch (error) {
} console.error("[Soleprint] Failed to initialize sidebar:", error);
}
}
async function checkAuth() {
if (!config.auth_enabled) return;
try {
const response = await fetch(`${SPR_BASE}/artery/google/oauth/status`, {
credentials: "include",
});
const data = await response.json(); const data = await response.json();
if (data.authenticated) {
// Store tokens user = data.user;
localStorage.setItem("access_token", data.access); }
localStorage.setItem("refresh_token", data.refresh);
// Store user info
localStorage.setItem(
"user_info",
JSON.stringify({
username: user.username,
label: user.label,
role: data.details?.role || user.role,
}),
);
this.showStatus(`✓ Logged in as ${user.label}`, "success");
this.currentUser = user;
this.updateCurrentUserDisplay();
// Reload page after short delay
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) { } catch (error) {
console.error("[Soleprint] Login error:", error); console.log("[Soleprint] Auth check failed:", error);
this.showStatus(`✗ Login failed: ${error.message}`, "error");
} }
} }
logout() { function createSidebar() {
localStorage.removeItem("access_token"); // Add body class
localStorage.removeItem("refresh_token"); document.body.classList.add("spr-sidebar-active");
localStorage.removeItem("user_info"); if (expanded) {
document.body.classList.add("spr-sidebar-expanded");
this.showStatus("✓ Logged out", "success");
this.currentUser = null;
this.updateCurrentUserDisplay();
// Reload page after short delay
setTimeout(() => {
window.location.reload();
}, 1000);
}
checkCurrentUser() {
const userInfo = localStorage.getItem("user_info");
if (userInfo) {
try {
this.currentUser = JSON.parse(userInfo);
this.updateCurrentUserDisplay();
} catch (error) {
console.error("[Soleprint] Failed to parse user info:", error);
}
} }
const sidebar = document.createElement("div");
sidebar.id = "spr-sidebar";
if (expanded) sidebar.classList.add("expanded");
sidebar.innerHTML = `
<div class="spr-sidebar-header">
<button class="spr-sidebar-item" onclick="sprSidebar.toggle()" title="Toggle sidebar (Ctrl+Shift+S)">
${icons.toggle}
<span class="label">Menu</span>
</button>
<div class="room-name">${config.room}</div>
</div>
<a href="/" class="spr-sidebar-item" title="Home">
${icons.home}
<span class="label">Home</span>
</a>
<div class="spr-sidebar-divider"></div>
<a href="${SPR_BASE}/" class="spr-sidebar-item" title="Soleprint">
${icons.soleprint}
<span class="label">Soleprint</span>
</a>
<a href="${config.tools.artery}" class="spr-sidebar-item" title="Artery">
${icons.artery}
<span class="label">Artery</span>
</a>
<a href="${config.tools.atlas}" class="spr-sidebar-item" title="Atlas">
${icons.atlas}
<span class="label">Atlas</span>
</a>
<a href="${config.tools.station}" class="spr-sidebar-item" title="Station">
${icons.station}
<span class="label">Station</span>
</a>
<div class="spr-sidebar-spacer"></div>
${config.auth_enabled ? renderAuthSection() : ""}
`;
document.body.appendChild(sidebar);
} }
updateCurrentUserDisplay() { function renderAuthSection() {
const display = document.getElementById("current-user-display"); if (user) {
const username = document.getElementById("current-username"); return `
<div class="spr-sidebar-user">
if (this.currentUser) { <div class="spr-sidebar-item" title="${user.email}">
display.style.display = "block"; ${icons.user}
username.textContent = this.currentUser.username; <span class="label">${user.email}</span>
</div>
// Highlight active user card <a href="${config.auth.logout_url}" class="spr-sidebar-item" title="Logout">
document.querySelectorAll(".user-card").forEach((card) => { ${icons.logout}
card.classList.remove("active"); <span class="label">Logout</span>
}); </a>
</div>
const activeCard = document.querySelector( `;
`.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`,
);
if (activeCard) {
activeCard.classList.add("active");
}
} else { } else {
display.style.display = "none"; return `
<div class="spr-sidebar-user">
<a href="${config.auth.login_url}" class="spr-sidebar-item spr-login-btn" title="Login with Google">
${icons.google}
<span class="label">Login with Google</span>
</a>
</div>
`;
} }
} }
getUserIdByUsername(username) { function toggleSidebar() {
const user = this.config.wrapper.users.find((u) => u.username === username); const sidebar = document.getElementById("spr-sidebar");
return user ? user.id : null; expanded = !expanded;
sidebar.classList.toggle("expanded", expanded);
document.body.classList.toggle("spr-sidebar-expanded", expanded);
localStorage.setItem("spr_sidebar_expanded", expanded);
} }
}
// Initialize sidebar when DOM is ready // Expose to global scope for onclick handlers
const soleprintSidebar = new SoleprintSidebar(); window.sprSidebar = {
toggle: toggleSidebar,
};
if (document.readyState === "loading") { // Initialize when DOM is ready
document.addEventListener("DOMContentLoaded", () => soleprintSidebar.init()); if (document.readyState === "loading") {
} else { document.addEventListener("DOMContentLoaded", init);
soleprintSidebar.init(); } else {
} init();
}
console.log("[Soleprint] Sidebar script loaded"); console.log("[Soleprint] Sidebar script loaded");
})();