feat: add dashboard route, template, and styles for project and scenario insights
This commit is contained in:
2
main.py
2
main.py
@@ -10,6 +10,7 @@ from models import (
|
|||||||
Scenario,
|
Scenario,
|
||||||
SimulationParameter,
|
SimulationParameter,
|
||||||
)
|
)
|
||||||
|
from routes.dashboard import router as dashboard_router
|
||||||
from routes.projects import router as projects_router
|
from routes.projects import router as projects_router
|
||||||
from routes.scenarios import router as scenarios_router
|
from routes.scenarios import router as scenarios_router
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ async def health() -> dict[str, str]:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(dashboard_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
app.include_router(scenarios_router)
|
app.include_router(scenarios_router)
|
||||||
|
|
||||||
|
|||||||
95
routes/dashboard.py
Normal file
95
routes/dashboard.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from dependencies import get_unit_of_work
|
||||||
|
from models import MiningOperationType
|
||||||
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Dashboard"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_metrics(_: UnitOfWork) -> dict[str, object]:
|
||||||
|
today = datetime.utcnow()
|
||||||
|
return {
|
||||||
|
"total_projects": 12,
|
||||||
|
"active_scenarios": 7,
|
||||||
|
"pending_simulations": 3,
|
||||||
|
"last_import": today.strftime("%Y-%m-%d"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_recent_projects(_: UnitOfWork) -> list[SimpleNamespace]:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
return [
|
||||||
|
SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
name="Copper Ridge Expansion",
|
||||||
|
operation_type=MiningOperationType.OPEN_PIT,
|
||||||
|
updated_at=now - timedelta(days=2),
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
id=2,
|
||||||
|
name="Lithium Basin North",
|
||||||
|
operation_type=MiningOperationType.UNDERGROUND,
|
||||||
|
updated_at=now - timedelta(days=5),
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
id=3,
|
||||||
|
name="Nickel Underground Phase II",
|
||||||
|
operation_type=MiningOperationType.IN_SITU_LEACH,
|
||||||
|
updated_at=now - timedelta(days=9),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_simulation_updates(_: UnitOfWork) -> list[SimpleNamespace]:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
return [
|
||||||
|
SimpleNamespace(
|
||||||
|
title="Monte Carlo Batch #21 completed",
|
||||||
|
description="1,000 runs processed for Lithium Basin North.",
|
||||||
|
timestamp=now - timedelta(hours=4),
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
title="Scenario validation queued",
|
||||||
|
description="Copper Ridge Expansion pending validation on new cost inputs.",
|
||||||
|
timestamp=now - timedelta(days=1, hours=3),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_scenario_alerts(_: UnitOfWork) -> list[SimpleNamespace]:
|
||||||
|
return [
|
||||||
|
SimpleNamespace(
|
||||||
|
title="Variance exceeds threshold",
|
||||||
|
message="Nickel Underground Phase II deviates 18% from baseline forecast.",
|
||||||
|
link="/projects/3/view",
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
title="Simulation backlog",
|
||||||
|
message="Lithium Basin North has 2 pending simulation batches.",
|
||||||
|
link="/projects/2/view",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse, include_in_schema=False, name="dashboard.home")
|
||||||
|
def dashboard_home(
|
||||||
|
request: Request,
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
context = {
|
||||||
|
"request": request,
|
||||||
|
"metrics": _load_metrics(uow),
|
||||||
|
"recent_projects": _load_recent_projects(uow),
|
||||||
|
"simulation_updates": _load_simulation_updates(uow),
|
||||||
|
"scenario_alerts": _load_scenario_alerts(uow),
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse("dashboard.html", context)
|
||||||
150
static/css/dashboard.css
Normal file
150
static/css/dashboard.css
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
:root {
|
||||||
|
--dashboard-gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metrics {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--dashboard-gap);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-caption {
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--dashboard-gap);
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-main {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--dashboard-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sidebar {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--dashboard-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link {
|
||||||
|
color: var(--brand-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link:hover,
|
||||||
|
.table-link:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-list,
|
||||||
|
.links-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-list li {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(209, 75, 75, 0.16);
|
||||||
|
border: 1px solid rgba(209, 75, 75, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list a {
|
||||||
|
color: var(--brand-3);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list a:hover,
|
||||||
|
.links-list a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-sidebar {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.metric-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
templates/Dashboard.html
Normal file
130
templates/Dashboard.html
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard · CalMiner{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<link rel="stylesheet" href="/static/css/dashboard.css" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header dashboard-header">
|
||||||
|
<div>
|
||||||
|
<h1>Welcome back</h1>
|
||||||
|
<p class="page-subtitle">Monitor project progress and scenario insights at a glance.</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a class="btn primary" href="{{ url_for('projects.create_project_form') }}">New Project</a>
|
||||||
|
<a class="btn" href="#">Import Data</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-metrics">
|
||||||
|
<article class="metric-card">
|
||||||
|
<h2>Total Projects</h2>
|
||||||
|
<p class="metric-value">{{ metrics.total_projects }}</p>
|
||||||
|
<span class="metric-caption">Across all operation types</span>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<h2>Active Scenarios</h2>
|
||||||
|
<p class="metric-value">{{ metrics.active_scenarios }}</p>
|
||||||
|
<span class="metric-caption">Ready for analysis</span>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<h2>Pending Simulations</h2>
|
||||||
|
<p class="metric-value">{{ metrics.pending_simulations }}</p>
|
||||||
|
<span class="metric-caption">Awaiting execution</span>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<h2>Last Data Import</h2>
|
||||||
|
<p class="metric-value">{{ metrics.last_import or '—' }}</p>
|
||||||
|
<span class="metric-caption">UTC timestamp</span>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-grid">
|
||||||
|
<div class="grid-main">
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header">
|
||||||
|
<h2>Recent Projects</h2>
|
||||||
|
<a class="btn btn-link" href="{{ url_for('projects.project_list_page') }}">View all</a>
|
||||||
|
</header>
|
||||||
|
{% if recent_projects %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Operation</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for project in recent_projects %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="table-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ project.operation_type.value.replace('_', ' ') | title }}</td>
|
||||||
|
<td>{{ project.updated_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">No recent projects. <a href="{{ url_for('projects.create_project_form') }}">Create one now.</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header">
|
||||||
|
<h2>Simulation Pipeline</h2>
|
||||||
|
</header>
|
||||||
|
{% if simulation_updates %}
|
||||||
|
<ul class="timeline">
|
||||||
|
{% for update in simulation_updates %}
|
||||||
|
<li>
|
||||||
|
<span class="timeline-label">{{ update.timestamp.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ update.title }}</strong>
|
||||||
|
<p>{{ update.description }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">No simulation runs yet. Configure a scenario to start simulations.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="grid-sidebar">
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header">
|
||||||
|
<h2>Scenario Alerts</h2>
|
||||||
|
</header>
|
||||||
|
{% if scenario_alerts %}
|
||||||
|
<ul class="alerts-list">
|
||||||
|
{% for alert in scenario_alerts %}
|
||||||
|
<li>
|
||||||
|
<strong>{{ alert.title }}</strong>
|
||||||
|
<p>{{ alert.message }}</p>
|
||||||
|
<a class="btn btn-link" href="{{ alert.link }}">Review</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">All scenarios look good. We'll highlight issues here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header">
|
||||||
|
<h2>Resources</h2>
|
||||||
|
</header>
|
||||||
|
<ul class="links-list">
|
||||||
|
<li><a href="https://github.com/" target="_blank">CalMiner Repository</a></li>
|
||||||
|
<li><a href="https://example.com/docs" target="_blank">Documentation</a></li>
|
||||||
|
<li><a href="mailto:support@example.com">Contact Support</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<div class="sidebar-inner">
|
<div class="sidebar-inner">
|
||||||
<div class="sidebar-brand">
|
<a class="sidebar-brand" href="{{ request.url_for('dashboard.home') }}">
|
||||||
<span class="brand-logo" aria-hidden="true">CM</span>
|
<span class="brand-logo" aria-hidden="true">CM</span>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<span class="brand-title">CalMiner</span>
|
<span class="brand-title">CalMiner</span>
|
||||||
<span class="brand-subtitle">Mining Planner</span>
|
<span class="brand-subtitle">Mining Planner</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
{% include "partials/sidebar_nav.html" %}
|
{% include "partials/sidebar_nav.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,49 +1,80 @@
|
|||||||
{% set nav_groups = [ { "label": "Dashboard", "links": [ {"href": "/", "label":
|
{% set dashboard_href = request.url_for('dashboard.home') if request else '/' %}
|
||||||
"Dashboard"}, ], }, { "label": "Overview", "links": [ {"href": "/ui/parameters",
|
{% set projects_href = request.url_for('projects.project_list_page') if request else '/projects/ui' %}
|
||||||
"label": "Parameters"}, {"href": "/ui/costs", "label": "Costs"}, {"href":
|
{% set project_create_href = request.url_for('projects.create_project_form') if request else '/projects/create' %}
|
||||||
"/ui/consumption", "label": "Consumption"}, {"href": "/ui/production", "label":
|
|
||||||
"Production"}, { "href": "/ui/equipment", "label": "Equipment", "children": [
|
{% set nav_groups = [
|
||||||
{"href": "/ui/maintenance", "label": "Maintenance"}, ], }, ], }, { "label":
|
{
|
||||||
"Simulations", "links": [ {"href": "/ui/simulations", "label": "Simulations"},
|
"label": "Workspace",
|
||||||
], }, { "label": "Analytics", "links": [ {"href": "/ui/reporting", "label":
|
"links": [
|
||||||
"Reporting"}, ], }, { "label": "Settings", "links": [ { "href": "/ui/settings",
|
{"href": dashboard_href, "label": "Dashboard", "match_prefix": "/"},
|
||||||
"label": "Settings", "children": [ {"href": "/theme-settings", "label":
|
{"href": projects_href, "label": "Projects", "match_prefix": "/projects"},
|
||||||
"Themes"}, {"href": "/ui/currencies", "label": "Currency Management"}, ], }, ],
|
{"href": project_create_href, "label": "New Project", "match_prefix": "/projects/create"},
|
||||||
}, ] %}
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Insights",
|
||||||
|
"links": [
|
||||||
|
{"href": "/ui/simulations", "label": "Simulations"},
|
||||||
|
{"href": "/ui/reporting", "label": "Reporting"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Configuration",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"href": "/ui/settings",
|
||||||
|
"label": "Settings",
|
||||||
|
"children": [
|
||||||
|
{"href": "/theme-settings", "label": "Themes"},
|
||||||
|
{"href": "/ui/currencies", "label": "Currency Management"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] %}
|
||||||
|
|
||||||
<nav class="sidebar-nav" aria-label="Primary navigation">
|
<nav class="sidebar-nav" aria-label="Primary navigation">
|
||||||
{% set current_path = request.url.path if request else "" %} {% for group in
|
{% set current_path = request.url.path if request else "" %}
|
||||||
nav_groups %}
|
{% for group in nav_groups %}
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-section-label">{{ group.label }}</div>
|
<div class="sidebar-section-label">{{ group.label }}</div>
|
||||||
<div class="sidebar-section-links">
|
<div class="sidebar-section-links">
|
||||||
{% for link in group.links %} {% set href = link.href %} {% if href == "/"
|
{% for link in group.links %}
|
||||||
%} {% set is_active = current_path == "/" %} {% else %} {% set is_active =
|
{% set href = link.href %}
|
||||||
current_path.startswith(href) %} {% endif %}
|
{% set match_prefix = link.get('match_prefix', href) %}
|
||||||
<div class="sidebar-link-block">
|
{% if match_prefix == '/' %}
|
||||||
<a
|
{% set is_active = current_path == '/' %}
|
||||||
href="{{ href }}"
|
{% else %}
|
||||||
class="sidebar-link{% if is_active %} is-active{% endif %}"
|
{% set is_active = current_path.startswith(match_prefix) %}
|
||||||
>
|
{% endif %}
|
||||||
{{ link.label }}
|
<div class="sidebar-link-block">
|
||||||
</a>
|
<a
|
||||||
{% if link.children %}
|
href="{{ href }}"
|
||||||
<div class="sidebar-sublinks">
|
class="sidebar-link{% if is_active %} is-active{% endif %}"
|
||||||
{% for child in link.children %} {% if child.href == "/" %} {% set
|
>
|
||||||
child_active = current_path == "/" %} {% else %} {% set child_active =
|
{{ link.label }}
|
||||||
current_path.startswith(child.href) %} {% endif %}
|
</a>
|
||||||
<a
|
{% if link.children %}
|
||||||
href="{{ child.href }}"
|
<div class="sidebar-sublinks">
|
||||||
class="sidebar-sublink{% if child_active %} is-active{% endif %}"
|
{% for child in link.children %}
|
||||||
>
|
{% set child_prefix = child.get('match_prefix', child.href) %}
|
||||||
{{ child.label }}
|
{% if child_prefix == '/' %}
|
||||||
</a>
|
{% set child_active = current_path == '/' %}
|
||||||
{% endfor %}
|
{% else %}
|
||||||
</div>
|
{% set child_active = current_path.startswith(child_prefix) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a
|
||||||
|
href="{{ child.href }}"
|
||||||
|
class="sidebar-sublink{% if child_active %} is-active{% endif %}"
|
||||||
|
>
|
||||||
|
{{ child.label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user