nuke all old UIs — templates, React SPA, /v2 mount, legacy routes
Removed: - src/web/templates/*.html (all 6 Jinja2 template files) - src/web/ui-dist/ (old React SPA build) - /v2 duplicate static mount (same as /) - /ui static mount (old React SPA) Server.py changes: - Root handler now directly serves v2 UI (no fallback chain) - Old template routes (/presets, /models, /irs, /settings) → redirect to / - Removed Jinja2Templates, TEMPLATES_DIR, TEMPLATE_VERSION, UI_DIST_DIR
This commit is contained in:
+23
-81
@@ -25,9 +25,8 @@ from typing import Any, Optional
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from ..dsp.nam_host import NAMHost
|
||||
from ..dsp.ir_loader import IRLoader
|
||||
@@ -51,38 +50,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Cache buster version (bumped on deploy) ─────────────────────────
|
||||
# Increment this when template/static changes need to bypass browser cache
|
||||
TEMPLATE_VERSION = "8"
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8080
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
# React UI dist — built frontend served at /ui/
|
||||
# Can be overridden via UI_DIST_DIR env var
|
||||
_ui_dist_env = os.environ.get("UI_DIST_DIR")
|
||||
if _ui_dist_env:
|
||||
UI_DIST_DIR = Path(_ui_dist_env).resolve()
|
||||
else:
|
||||
# Priority: src/web/ui-dist is the canonical UI
|
||||
_candidates = [
|
||||
Path(__file__).resolve().parent / "ui-dist",
|
||||
Path(__file__).resolve().parent.parent.parent / "frontend-react" / "dist",
|
||||
]
|
||||
UI_DIST_DIR = next((d for d in _candidates if d.is_dir()), None)
|
||||
|
||||
# ── Channel support ──────────────────────────────────────────────────────────────
|
||||
|
||||
VALID_CHANNELS: frozenset = frozenset({"guitar", "bass", "keys", "vocals", "backing_tracks"})
|
||||
|
||||
def _resolve_channel(channel: str = "guitar") -> str:
|
||||
"""Normalize a channel string; fall back to 'guitar' for invalid values."""
|
||||
if isinstance(channel, str) and channel.lower() in VALID_CHANNELS:
|
||||
return channel.lower()
|
||||
return "guitar"
|
||||
|
||||
# v2 UI dist (the only UI we serve)
|
||||
V2_DIST_DIR = Path(__file__).resolve().parent / "ui-v2-dist"
|
||||
|
||||
# ── Channel mode persistence ────────────────────────────────────────────────
|
||||
|
||||
@@ -315,10 +290,6 @@ class WebServer:
|
||||
|
||||
def _build_app(self) -> FastAPI:
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
# Inject version into all templates (cache buster for CSS/JS)
|
||||
templates.env.globals["version"] = TEMPLATE_VERSION
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Web server lifespan started")
|
||||
@@ -330,63 +301,34 @@ class WebServer:
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Mount React UI (if dist exists)
|
||||
if UI_DIST_DIR and UI_DIST_DIR.is_dir():
|
||||
logger.info("Mounting React UI from %s at /ui", UI_DIST_DIR)
|
||||
app.mount("/ui", StaticFiles(directory=str(UI_DIST_DIR), html=True), name="ui")
|
||||
|
||||
# Mount V2 React UI (if dist exists)
|
||||
V2_DIST_DIR = Path(__file__).resolve().parent / "ui-v2-dist"
|
||||
if V2_DIST_DIR.is_dir():
|
||||
logger.info("Mounting V2 React UI from %s at /v2", V2_DIST_DIR)
|
||||
app.mount("/v2", StaticFiles(directory=str(V2_DIST_DIR), html=True), name="ui-v2")
|
||||
|
||||
# ── HTML pages ──────────────────────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
"""Serve the v2 UI (or React SPA / legacy dashboard as fallback)."""
|
||||
# Prefer the v2 UI if available
|
||||
v2_dir = Path(__file__).resolve().parent / "ui-v2-dist"
|
||||
if v2_dir.is_dir():
|
||||
v2_index = v2_dir / "index.html"
|
||||
if v2_index.exists():
|
||||
content = v2_index.read_text()
|
||||
return HTMLResponse(content)
|
||||
# Fallback: React build
|
||||
if UI_DIST_DIR and UI_DIST_DIR.is_dir():
|
||||
index_html = UI_DIST_DIR / "index.html"
|
||||
if index_html.exists():
|
||||
content = index_html.read_text()
|
||||
return HTMLResponse(content)
|
||||
# Fallback: legacy dashboard (guitar channel state)
|
||||
state = self._gather_state(channel="guitar")
|
||||
ctx = {"request": request}
|
||||
ctx.update(state)
|
||||
return templates.TemplateResponse(request, "dashboard.html", ctx)
|
||||
"""Serve the v2 UI."""
|
||||
v2_index = V2_DIST_DIR / "index.html"
|
||||
if v2_index.exists():
|
||||
content = v2_index.read_text()
|
||||
return HTMLResponse(content)
|
||||
return HTMLResponse("<h1>Pi Multi-FX Pedal</h1><p>UI build not found.</p>")
|
||||
|
||||
@app.get("/presets", response_class=HTMLResponse)
|
||||
async def preset_manager(request: Request):
|
||||
"""Preset manager page — browse banks, load/save/edit."""
|
||||
return templates.TemplateResponse(request, "presets.html", {"request": request})
|
||||
# Legacy routes — redirect to the v2 SPA
|
||||
|
||||
@app.get("/models", response_class=HTMLResponse)
|
||||
async def model_manager(request: Request):
|
||||
"""NAM model manager page."""
|
||||
return templates.TemplateResponse(request, "models.html", {"request": request})
|
||||
@app.get("/presets")
|
||||
async def presets_redirect():
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
@app.get("/irs", response_class=HTMLResponse)
|
||||
async def ir_manager(request: Request):
|
||||
"""IR manager page."""
|
||||
return templates.TemplateResponse(request, "irs.html", {"request": request})
|
||||
@app.get("/models")
|
||||
async def models_redirect():
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
"""Settings page."""
|
||||
state = self._gather_state(channel="guitar")
|
||||
ctx = {"request": request}
|
||||
ctx.update(state)
|
||||
return templates.TemplateResponse(request, "settings.html", ctx)
|
||||
@app.get("/irs")
|
||||
async def irs_redirect():
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
@app.get("/settings")
|
||||
async def settings_redirect():
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
# ── REST API ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>{% block title %}Pi Multi-FX Pedal{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ version }}">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>🎸</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<a href="/" class="logo">🎸 Pi Pedal</a>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/" class="nav-link {% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
||||
<a href="/presets" class="nav-link {% if request.url.path == '/presets' %}active{% endif %}">Presets</a>
|
||||
<a href="/models" class="nav-link {% if request.url.path == '/models' %}active{% endif %}">Models</a>
|
||||
<a href="/irs" class="nav-link {% if request.url.path == '/irs' %}active{% endif %}">IRs</a>
|
||||
<a href="/settings" class="nav-link {% if request.url.path == '/settings' %}active{% endif %}">Settings</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<span class="connection-indicator" id="connection-status">Disconnected</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/websocket.js?v={{ version }}"></script>
|
||||
<script src="/static/app.js?v={{ version }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,114 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<!-- Current Preset Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Current Preset</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="preset-name-display" id="preset-name">{{ current_preset.name if current_preset else "—" }}</div>
|
||||
<div class="preset-meta" id="preset-meta">
|
||||
Bank {{ current_preset.bank if current_preset else 0 }} — Program {{ current_preset.program if current_preset else 0 }}
|
||||
</div>
|
||||
<div class="preset-controls">
|
||||
<button class="btn btn-primary" onclick="activatePreset('prev', -1)">◀ Prev</button>
|
||||
<button class="btn btn-primary" onclick="activatePreset('next', 1)">Next ▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Row -->
|
||||
<div class="status-row">
|
||||
<div class="card status-card">
|
||||
<div class="status-label">Bypass</div>
|
||||
<div class="status-value">
|
||||
<button class="btn toggle-btn {% if bypass %}active{% endif %}" id="bypass-btn" onclick="toggleBypass()">
|
||||
{% if bypass %}BYPASSED{% else %}ACTIVE{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card status-card">
|
||||
<div class="status-label">Tuner</div>
|
||||
<div class="status-value">
|
||||
<button class="btn toggle-btn {% if tuner_enabled %}active{% endif %}" id="tuner-btn" onclick="toggleTuner()">
|
||||
{% if tuner_enabled %}ON{% else %}OFF{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card status-card">
|
||||
<div class="status-label">Volume</div>
|
||||
<div class="status-value">
|
||||
<input type="range" class="slider" id="volume-slider"
|
||||
min="0" max="100" value="{{ (master_volume * 100)|int }}"
|
||||
oninput="setVolume(this.value)">
|
||||
<span class="volume-text" id="volume-text">{{ (master_volume * 100)|int }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FX Chain -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>FX Chain</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="fx-chain" id="fx-chain">
|
||||
<div class="fx-block-chain-placeholder" id="fx-chain-placeholder">
|
||||
<p>Connect to pedal to see active FX chain.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAM Model Status -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>NAM Model</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="model-status" id="model-status">
|
||||
<span class="model-name" id="model-name">{{ nam_model if nam_model else "None loaded" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IR Status -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>IR Cabinet</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="ir-status" id="ir-status">
|
||||
<span class="ir-name" id="ir-name">{{ ir_name if ir_name else "None loaded" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4CM Routing Status -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Routing</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="routing-status">
|
||||
<span class="routing-indicator" id="routing-mode-indicator" data-mode="{{ routing_mode }}">
|
||||
{% if routing_mode == '4cm' %}
|
||||
<span class="badge badge-4cm">4CM</span>
|
||||
{% else %}
|
||||
<span class="badge badge-mono">Mono</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="routing-info" id="routing-info">
|
||||
{% if routing_mode == '4cm' %}
|
||||
Breakpoint: {{ routing_breakpoint }}
|
||||
{% else %}
|
||||
Full chain
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,69 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}IR Cabinet — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page irs-page">
|
||||
<div class="page-header">
|
||||
<h1>IR Manager</h1>
|
||||
<button class="btn btn-secondary" onclick="loadIRs()">⟳ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Current IR</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="current-ir" id="current-ir">
|
||||
<span class="ir-name">None loaded</span>
|
||||
</div>
|
||||
<div class="ir-controls">
|
||||
<button class="btn btn-danger" onclick="unloadIR()" id="unload-ir-btn" style="display:none">Unload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Available IR Files</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="ir-list" id="ir-list">
|
||||
<p class="loading">Loading IR files...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Upload IR</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="hint">Upload .wav IR files to the pedal's IR directory.</p>
|
||||
<input type="file" id="ir-upload-input" accept=".wav" class="file-input">
|
||||
<button class="btn btn-primary" onclick="uploadIR()">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Browse Tone3000 <span class="badge badge-tone3000">BETA</span></h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="hint">Search and download impulse responses directly from the Tone3000 community.</p>
|
||||
<div class="tonedownload-search-row">
|
||||
<input type="text" id="ir-tone-search" class="file-input tonedownload-input"
|
||||
placeholder="Search IRs (e.g. Celestion V30)..." onkeydown="if(event.key==='Enter')searchToneIRs()">
|
||||
<button class="btn btn-primary" onclick="searchToneIRs()">Search</button>
|
||||
<button class="btn btn-secondary" onclick="loadTrendingIRs()">Trending</button>
|
||||
</div>
|
||||
<div id="ir-tone-loader" class="loading" style="display:none">Searching Tone3000...</div>
|
||||
<div id="ir-tone-offline" class="tonedownload-offline" style="display:none">
|
||||
<span>⚠ Offline — search unavailable</span>
|
||||
</div>
|
||||
<div id="ir-tone-results" class="tonedownload-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/irs.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,69 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}NAM Models — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page models-page">
|
||||
<div class="page-header">
|
||||
<h1>NAM Model Manager</h1>
|
||||
<button class="btn btn-secondary" onclick="loadModels()">⟳ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Current Model</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="current-model" id="current-model">
|
||||
<span class="model-name">None loaded</span>
|
||||
</div>
|
||||
<div class="model-controls">
|
||||
<button class="btn btn-danger" onclick="unloadModel()" id="unload-model-btn" style="display:none">Unload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Available Models</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="model-list" id="model-list">
|
||||
<p class="loading">Loading models...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Upload Model</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="hint">Upload .nam model files to the pedal's model directory.</p>
|
||||
<input type="file" id="model-upload-input" accept=".nam" class="file-input">
|
||||
<button class="btn btn-primary" onclick="uploadModel()">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Browse Tone3000 <span class="badge badge-tone3000">BETA</span></h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="hint">Search and download NAM models directly from the Tone3000 community.</p>
|
||||
<div class="tonedownload-search-row">
|
||||
<input type="text" id="model-tone-search" class="file-input tonedownload-input"
|
||||
placeholder="Search models (e.g. Marshall JCM800)..." onkeydown="if(event.key==='Enter')searchToneModels()">
|
||||
<button class="btn btn-primary" onclick="searchToneModels()">Search</button>
|
||||
<button class="btn btn-secondary" onclick="loadTrendingModels()">Trending</button>
|
||||
</div>
|
||||
<div id="model-tone-loader" class="loading" style="display:none">Searching Tone3000...</div>
|
||||
<div id="model-tone-offline" class="tonedownload-offline" style="display:none">
|
||||
<span>⚠ Offline — search unavailable</span>
|
||||
</div>
|
||||
<div id="model-tone-results" class="tonedownload-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/models.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,34 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Presets — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page presets-page">
|
||||
<div class="page-header">
|
||||
<h1>Preset Manager</h1>
|
||||
<button class="btn btn-secondary" onclick="loadPresets()">⟳ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="bank-list" id="bank-list">
|
||||
<p class="loading">Loading presets...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="preset-modal" style="display:none" onclick="closeModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Edit Preset</h2>
|
||||
<button class="btn-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body">
|
||||
<!-- Dynamic content loaded by JS -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" id="save-preset-btn" onclick="savePreset()">Save</button>
|
||||
<button class="btn btn-danger" onclick="deletePreset()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/presets.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,381 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Pi Multi-FX Pedal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page settings-page">
|
||||
<div class="page-header">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="settings-layout">
|
||||
<!-- Left sidebar navigation -->
|
||||
<nav class="settings-sidebar">
|
||||
<a class="settings-nav-item active" href="#connection" data-tab="connection" onclick="switchTab('connection')">
|
||||
<span class="settings-nav-icon">🔌</span>
|
||||
<span class="settings-nav-label">Connection</span>
|
||||
</a>
|
||||
<a class="settings-nav-item" href="#audio" data-tab="audio" onclick="switchTab('audio')">
|
||||
<span class="settings-nav-icon">🎚️</span>
|
||||
<span class="settings-nav-label">Audio</span>
|
||||
</a>
|
||||
<a class="settings-nav-item" href="#routing" data-tab="routing" onclick="switchTab('routing')">
|
||||
<span class="settings-nav-icon">🔀</span>
|
||||
<span class="settings-nav-label">4CM Routing</span>
|
||||
</a>
|
||||
<a class="settings-nav-item" href="#network" data-tab="network" onclick="switchTab('network')">
|
||||
<span class="settings-nav-icon">📶</span>
|
||||
<span class="settings-nav-label">Network</span>
|
||||
</a>
|
||||
<a class="settings-nav-item" href="#bluetooth" data-tab="bluetooth" onclick="switchTab('bluetooth')">
|
||||
<span class="settings-nav-icon">🔵</span>
|
||||
<span class="settings-nav-label">Bluetooth</span>
|
||||
</a>
|
||||
<a class="settings-nav-item" href="#about" data-tab="about" onclick="switchTab('about')">
|
||||
<span class="settings-nav-icon">ℹ️</span>
|
||||
<span class="settings-nav-label">About</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="settings-content">
|
||||
<!-- Tab: Connection -->
|
||||
<div class="settings-panel active" id="tab-connection">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Connection</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Hostname</div>
|
||||
<div class="setting-value"><code id="hostname">pedal.local</code></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Web Server</div>
|
||||
<div class="setting-value"><code id="web-url">http://pedal.local:8080</code></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Connected Clients</div>
|
||||
<div class="setting-value"><span id="ws-count">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Audio -->
|
||||
<div class="settings-panel" id="tab-audio">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Audio</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Master Volume</div>
|
||||
<div class="setting-value">
|
||||
<input type="range" class="slider" id="settings-volume"
|
||||
min="0" max="100" value="{{ (master_volume * 100)|int }}"
|
||||
oninput="setVolume(this.value)">
|
||||
<span id="settings-volume-text">{{ (master_volume * 100)|int }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Tuner</div>
|
||||
<div class="setting-value">
|
||||
<button class="btn toggle-btn {% if tuner_enabled %}active{% endif %}"
|
||||
id="settings-tuner-btn" onclick="toggleTuner()">
|
||||
{% if tuner_enabled %}ON{% else %}OFF{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="border-bottom:none">
|
||||
<div class="setting-label">Latency Profile</div>
|
||||
<div class="setting-value">
|
||||
<select id="audio-profile-select" class="profile-select"
|
||||
onchange="switchAudioProfile(this.value)">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="border-bottom:none">
|
||||
<div class="setting-label">JACK Status</div>
|
||||
<div class="setting-value">
|
||||
<span id="jack-status" class="jack-check">—</span>
|
||||
<button class="btn btn-secondary btn-sm" onclick="refreshAudioProfile()">⟳</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: 4CM Routing -->
|
||||
<div class="settings-panel" id="tab-routing">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>4-Cable Method (4CM)</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">4CM Mode</div>
|
||||
<div class="setting-value">
|
||||
<button class="btn toggle-btn {% if routing_mode == '4cm' %}active{% endif %}"
|
||||
id="4cm-toggle-btn" onclick="toggle4cm()">
|
||||
{% if routing_mode == '4cm' %}ON{% else %}OFF{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" id="breakpoint-row"
|
||||
{% if routing_mode != '4cm' %}style="opacity: 0.4; pointer-events: none;"{% endif %}>
|
||||
<div class="setting-label">Breakpoint</div>
|
||||
<div class="setting-value">
|
||||
<input type="range" class="slider" id="4cm-breakpoint"
|
||||
min="0" max="16" value="{{ routing_breakpoint }}"
|
||||
oninput="set4cmBreakpoint(this.value)">
|
||||
<span id="4cm-breakpoint-text">{{ routing_breakpoint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Routing</div>
|
||||
<div class="setting-value">
|
||||
<span id="4cm-routing-desc" class="routing-desc">
|
||||
{% if routing_mode == '4cm' %}
|
||||
Input 1 (Guitar) → Pre blocks [0..{{ routing_breakpoint }}) → Send
|
||||
| Input 2 (Return) → Post blocks [{{ routing_breakpoint }}..] → Output
|
||||
{% else %}
|
||||
Mono: Guitar → Full chain → Output
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Network -->
|
||||
<div class="settings-panel" id="tab-network">
|
||||
<!-- WiFi Client Status -->
|
||||
<div class="card" id="wifi-status-card">
|
||||
<div class="card-header"><h2>WiFi Status</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="network-status-row">
|
||||
<div class="network-status-icon" id="wifi-icon">📶</div>
|
||||
<div class="network-status-info">
|
||||
<div class="network-ssid" id="wifi-ssid">—</div>
|
||||
<div class="network-ip" id="wifi-ip">—</div>
|
||||
<div class="network-signal"><span id="wifi-signal-text">—</span></div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="refreshWiFiStatus()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Scan & Connect -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>WiFi Networks</h2>
|
||||
<button class="btn btn-secondary" onclick="scanWiFi()" id="wifi-scan-btn">Scan</button>
|
||||
</div>
|
||||
<div class="card-body" id="wifi-scan-results">
|
||||
<div class="hint">Click Scan to find available WiFi networks.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Known Networks -->
|
||||
<div class="card" id="wifi-saved-card">
|
||||
<div class="card-header"><h2>Known Networks</h2></div>
|
||||
<div class="card-body" id="wifi-saved-list">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hotspot -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>WiFi Hotspot</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="hotspot-controls">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Hotspot Status</div>
|
||||
<div class="setting-value">
|
||||
<span id="hotspot-status-indicator" class="hotspot-indicator hotspot-off">Off</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" id="hotspot-ssid-row">
|
||||
<div class="setting-label">SSID</div>
|
||||
<div class="setting-value">
|
||||
<input type="text" class="network-input" id="hotspot-ssid" value="Pi-Pedal" maxlength="32">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" id="hotspot-psk-row">
|
||||
<div class="setting-label">Password</div>
|
||||
<div class="setting-value">
|
||||
<input type="password" class="network-input" id="hotspot-psk" value="pedal1234" maxlength="63">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Connected Clients</div>
|
||||
<div class="setting-value"><span id="hotspot-clients">0</span></div>
|
||||
</div>
|
||||
<div class="hotspot-actions">
|
||||
<button class="btn btn-primary" id="hotspot-enable-btn" onclick="enableHotspot()">Enable Hotspot</button>
|
||||
<button class="btn btn-danger" id="hotspot-disable-btn" onclick="disableHotspot()">Disable Hotspot</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Bluetooth -->
|
||||
<div class="settings-panel" id="tab-bluetooth">
|
||||
<!-- BT Adapter Status -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Bluetooth</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Bluetooth</div>
|
||||
<div class="setting-value">
|
||||
<button class="btn toggle-btn" id="bt-power-btn" onclick="toggleBTPower()">OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Discoverable</div>
|
||||
<div class="setting-value">
|
||||
<button class="btn toggle-btn" id="bt-discoverable-btn" onclick="toggleBTDiscoverable()">OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Adapter Name</div>
|
||||
<div class="setting-value"><span id="bt-adapter-name">—</span></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Address</div>
|
||||
<div class="setting-value"><code id="bt-address">—</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BT Scan -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Discover Devices</h2>
|
||||
<button class="btn btn-secondary" onclick="scanBT()" id="bt-scan-btn">Scan</button>
|
||||
</div>
|
||||
<div class="card-body" id="bt-scan-results">
|
||||
<div class="hint">Click Scan to find discoverable Bluetooth devices.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paired Devices -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Paired Devices</h2></div>
|
||||
<div class="card-body" id="bt-paired-list">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BT MIDI Service -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>Bluetooth MIDI</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">BT MIDI Service</div>
|
||||
<div class="setting-value">
|
||||
<span class="bt-midi-indicator" id="bt-midi-indicator">Off</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-midi-actions">
|
||||
<button class="btn btn-primary" id="bt-midi-enable-btn" onclick="enableBTMIDI()">Enable</button>
|
||||
<button class="btn btn-danger" id="bt-midi-disable-btn" onclick="disableBTMIDI()">Disable</button>
|
||||
</div>
|
||||
<div class="setting-row mt-8">
|
||||
<div class="setting-label">Connected MIDI Devices</div>
|
||||
<div class="setting-value" id="bt-midi-devices">
|
||||
<span class="hint">None connected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: About -->
|
||||
<div class="settings-panel" id="tab-about">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>About</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="about-info">
|
||||
<p><strong>Pi Multi-FX Pedal</strong> v0.1.0</p>
|
||||
<p>Raspberry Pi 4B — Python 3.11 — JACK Audio</p>
|
||||
<p><a href="https://gitea.ourpad.casa/shawn/pi-multifx-pedal" target="_blank">Gitea Repository</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WiFi connect modal -->
|
||||
<div class="modal-overlay" id="wifi-connect-modal" style="display:none;" onclick="if(event.target===this)closeWiFiModal()">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>Connect to <span id="wifi-modal-ssid"></span></h2>
|
||||
<button class="btn-close" onclick="closeWiFiModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">Password</div>
|
||||
<div class="setting-value">
|
||||
<input type="password" class="network-input" id="wifi-modal-password" placeholder="Enter WiFi password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="connectWiFi()">Connect</button>
|
||||
<button class="btn btn-secondary" onclick="closeWiFiModal()">Cancel</button>
|
||||
</div>
|
||||
<div id="wifi-modal-error" class="network-error mt-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/network.js"></script>
|
||||
<script src="/static/bluetooth.js"></script>
|
||||
<script>
|
||||
/* ── Tab switching (left nav) ──────────────────────────────────── */
|
||||
function switchTab(tabId) {
|
||||
// Update nav items
|
||||
document.querySelectorAll('.settings-nav-item').forEach(a => a.classList.remove('active'));
|
||||
document.querySelector(`.settings-nav-item[data-tab="${tabId}"]`).classList.add('active');
|
||||
|
||||
// Update panels
|
||||
document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById(`tab-${tabId}`).classList.add('active');
|
||||
|
||||
// Sync URL hash
|
||||
if (window.location.hash !== `#${tabId}`) {
|
||||
history.pushState(null, '', `#${tabId}`);
|
||||
}
|
||||
|
||||
// Refresh tab data when switching to it
|
||||
if (tabId === 'network') {
|
||||
refreshWiFiStatus();
|
||||
loadSavedNetworks();
|
||||
} else if (tabId === 'bluetooth') {
|
||||
refreshBTStatus();
|
||||
refreshBTMIDIStatus();
|
||||
loadPairedDevices();
|
||||
} else if (tabId === 'audio') {
|
||||
refreshAudioProfile();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Initial load & hash routing ───────────────────────────────── */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
const valid = ['connection', 'audio', 'routing', 'network', 'bluetooth', 'about'];
|
||||
if (valid.includes(hash)) {
|
||||
switchTab(hash);
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Listen for back/forward navigation ───────────────────────── */
|
||||
window.addEventListener('popstate', () => {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
const valid = ['connection', 'audio', 'routing', 'network', 'bluetooth', 'about'];
|
||||
if (valid.includes(hash)) {
|
||||
switchTab(hash);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ui</title>
|
||||
<script type="module" crossorigin src="/ui/assets/index-DFFBeBW_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-D3aC6klh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user