codex-fastapi-cabinet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_fastapi_cabinet-0.1.0.dist-info/METADATA +182 -0
- codex_fastapi_cabinet-0.1.0.dist-info/RECORD +39 -0
- codex_fastapi_cabinet-0.1.0.dist-info/WHEEL +4 -0
- codex_fastapi_cabinet-0.1.0.dist-info/licenses/LICENSE +190 -0
- fastapi_cabinet/__init__.py +90 -0
- fastapi_cabinet/contracts/__init__.py +86 -0
- fastapi_cabinet/contracts/admin.py +50 -0
- fastapi_cabinet/contracts/layout.py +22 -0
- fastapi_cabinet/contracts/navigation.py +29 -0
- fastapi_cabinet/contracts/pages.py +164 -0
- fastapi_cabinet/contracts/permissions.py +16 -0
- fastapi_cabinet/contracts/providers.py +9 -0
- fastapi_cabinet/contracts/widgets.py +102 -0
- fastapi_cabinet/exceptions.py +18 -0
- fastapi_cabinet/fastapi.py +36 -0
- fastapi_cabinet/loader.py +68 -0
- fastapi_cabinet/registry.py +31 -0
- fastapi_cabinet/rendering/__init__.py +5 -0
- fastapi_cabinet/rendering/layout_mapper.py +88 -0
- fastapi_cabinet/rendering/page_mapper.py +44 -0
- fastapi_cabinet/rendering/widget_mapper.py +59 -0
- fastapi_cabinet/runtime.py +58 -0
- fastapi_cabinet/site.py +262 -0
- fastapi_cabinet/static/css/cabinet.css +126 -0
- fastapi_cabinet/static/js/cabinet.js +6 -0
- fastapi_cabinet/templates/cabinet/base.html +21 -0
- fastapi_cabinet/templates/cabinet/dashboard.html +14 -0
- fastapi_cabinet/templates/cabinet/includes/sidebar.html +20 -0
- fastapi_cabinet/templates/cabinet/includes/topbar.html +21 -0
- fastapi_cabinet/templates/cabinet/includes/widget_frame.html +3 -0
- fastapi_cabinet/templates/cabinet/module.html +18 -0
- fastapi_cabinet/templates/cabinet/pages/detail.html +39 -0
- fastapi_cabinet/templates/cabinet/pages/form.html +47 -0
- fastapi_cabinet/templates/cabinet/pages/list.html +81 -0
- fastapi_cabinet/templates/cabinet/pages/operation.html +29 -0
- fastapi_cabinet/templates/cabinet/widgets/chart.html +20 -0
- fastapi_cabinet/templates/cabinet/widgets/list.html +8 -0
- fastapi_cabinet/templates/cabinet/widgets/metric.html +9 -0
- fastapi_cabinet/templates/cabinet/widgets/table.html +43 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from fastapi_cabinet.contracts.admin import CabinetAdmin
|
|
2
|
+
from fastapi_cabinet.contracts.pages import CabinetPage
|
|
3
|
+
from fastapi_cabinet.registry import CabinetRegistry
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_mount_path(mount_path: str) -> str:
|
|
7
|
+
normalized = "/" + mount_path.strip("/")
|
|
8
|
+
return "/" if normalized == "/" else normalized
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def admin_public_path(admin: CabinetAdmin, mount_path: str = "/cabinet") -> str:
|
|
12
|
+
if admin.path:
|
|
13
|
+
return "/" + admin.path.strip("/")
|
|
14
|
+
mount = normalize_mount_path(mount_path).rstrip("/")
|
|
15
|
+
return f"{mount}/{admin.key}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def admin_route_path(admin: CabinetAdmin, mount_path: str = "/cabinet") -> str:
|
|
19
|
+
public_path = admin_public_path(admin, mount_path)
|
|
20
|
+
mount = normalize_mount_path(mount_path).rstrip("/")
|
|
21
|
+
if public_path == mount:
|
|
22
|
+
return ""
|
|
23
|
+
if public_path.startswith(f"{mount}/"):
|
|
24
|
+
return public_path.removeprefix(mount)
|
|
25
|
+
return public_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def page_public_path(admin: CabinetAdmin, page: CabinetPage, mount_path: str = "/cabinet") -> str:
|
|
29
|
+
admin_path = admin_public_path(admin, mount_path).rstrip("/")
|
|
30
|
+
suffix = page.path.strip("/")
|
|
31
|
+
return admin_path if not suffix else f"{admin_path}/{suffix}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def action_public_path(admin: CabinetAdmin, action_path: str, mount_path: str = "/cabinet") -> str:
|
|
35
|
+
admin_path = admin_public_path(admin, mount_path).rstrip("/")
|
|
36
|
+
suffix = action_path.strip("/")
|
|
37
|
+
return admin_path if not suffix else f"{admin_path}/{suffix}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_active_admin(
|
|
41
|
+
request_path: str,
|
|
42
|
+
registry: CabinetRegistry,
|
|
43
|
+
mount_path: str = "/cabinet",
|
|
44
|
+
) -> CabinetAdmin | None:
|
|
45
|
+
normalized_request_path = "/" + request_path.strip("/")
|
|
46
|
+
matches = [
|
|
47
|
+
admin
|
|
48
|
+
for admin in registry.all()
|
|
49
|
+
if _path_matches(normalized_request_path, admin_public_path(admin, mount_path))
|
|
50
|
+
]
|
|
51
|
+
if not matches:
|
|
52
|
+
return None
|
|
53
|
+
return max(matches, key=lambda admin: len(admin_public_path(admin, mount_path)))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _path_matches(request_path: str, candidate_path: str) -> bool:
|
|
57
|
+
normalized_candidate = "/" + candidate_path.strip("/")
|
|
58
|
+
return request_path == normalized_candidate or request_path.startswith(f"{normalized_candidate}/")
|
fastapi_cabinet/site.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, HTTPException, Request, status
|
|
5
|
+
from fastapi.templating import Jinja2Templates
|
|
6
|
+
from jinja2 import BaseLoader, ChoiceLoader, FileSystemLoader
|
|
7
|
+
from starlette.responses import RedirectResponse, Response
|
|
8
|
+
|
|
9
|
+
from fastapi_cabinet.contracts.admin import ActionRoute, CabinetAdmin
|
|
10
|
+
from fastapi_cabinet.contracts.navigation import SidebarItem
|
|
11
|
+
from fastapi_cabinet.contracts.pages import CabinetPage
|
|
12
|
+
from fastapi_cabinet.contracts.permissions import AllowAllPermissionProvider, PermissionProvider
|
|
13
|
+
from fastapi_cabinet.contracts.widgets import DashboardWidget
|
|
14
|
+
from fastapi_cabinet.exceptions import CabinetRegistrationError
|
|
15
|
+
from fastapi_cabinet.registry import CabinetRegistry
|
|
16
|
+
from fastapi_cabinet.rendering.layout_mapper import build_layout_map
|
|
17
|
+
from fastapi_cabinet.rendering.page_mapper import resolve_page
|
|
18
|
+
from fastapi_cabinet.rendering.widget_mapper import resolve_widgets
|
|
19
|
+
from fastapi_cabinet.runtime import action_public_path, admin_public_path, normalize_mount_path, page_public_path
|
|
20
|
+
|
|
21
|
+
_PACKAGE_DIR = Path(__file__).parent
|
|
22
|
+
_PACKAGE_TEMPLATE_DIR = _PACKAGE_DIR / "templates"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CabinetSite:
|
|
26
|
+
"""Application-scoped cabinet composition root."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
brand_name: str = "Cabinet",
|
|
32
|
+
permission_provider: PermissionProvider | None = None,
|
|
33
|
+
templates: Jinja2Templates | None = None,
|
|
34
|
+
template_directories: Sequence[str | Path] = (),
|
|
35
|
+
) -> None:
|
|
36
|
+
self.brand_name = brand_name
|
|
37
|
+
self.permission_provider = permission_provider or AllowAllPermissionProvider()
|
|
38
|
+
self.registry = CabinetRegistry()
|
|
39
|
+
self.templates = _build_templates(templates, template_directories)
|
|
40
|
+
self.static_mount_path: str | None = None
|
|
41
|
+
|
|
42
|
+
def register(self, admin: type[CabinetAdmin] | CabinetAdmin) -> CabinetAdmin:
|
|
43
|
+
return self.registry.register(admin)
|
|
44
|
+
|
|
45
|
+
def build_router(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
mount_path: str = "/cabinet",
|
|
49
|
+
static_mount_path: str | None = None,
|
|
50
|
+
) -> APIRouter:
|
|
51
|
+
mount = normalize_mount_path(mount_path)
|
|
52
|
+
assets = static_mount_path or f"{mount.rstrip('/')}/static"
|
|
53
|
+
router = APIRouter()
|
|
54
|
+
|
|
55
|
+
@router.get(mount, name="cabinet:dashboard")
|
|
56
|
+
async def dashboard(request: Request) -> Response:
|
|
57
|
+
admins = await self._visible_admins(request)
|
|
58
|
+
if admins:
|
|
59
|
+
return RedirectResponse(url=admin_public_path(admins[0], mount), status_code=303)
|
|
60
|
+
layout = build_layout_map(
|
|
61
|
+
admins,
|
|
62
|
+
mount_path=mount,
|
|
63
|
+
static_mount_path=assets,
|
|
64
|
+
brand_name=self.brand_name,
|
|
65
|
+
active_admin=None,
|
|
66
|
+
active_path=str(request.url.path),
|
|
67
|
+
)
|
|
68
|
+
return self.templates.TemplateResponse(
|
|
69
|
+
request=request,
|
|
70
|
+
name="cabinet/dashboard.html",
|
|
71
|
+
context=self._context(request, layout=layout, modules=admins),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for admin in self.registry.all():
|
|
75
|
+
self._register_admin_routes(router, admin, mount, assets)
|
|
76
|
+
return router
|
|
77
|
+
|
|
78
|
+
def _register_admin_routes(
|
|
79
|
+
self,
|
|
80
|
+
router: APIRouter,
|
|
81
|
+
admin: CabinetAdmin,
|
|
82
|
+
mount_path: str,
|
|
83
|
+
static_mount_path: str,
|
|
84
|
+
) -> None:
|
|
85
|
+
router.get(admin_public_path(admin, mount_path), name=f"cabinet:{admin.key}")(
|
|
86
|
+
self._build_module_endpoint(admin, mount_path, static_mount_path)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for action in sorted(admin.actions, key=lambda item: item.path):
|
|
90
|
+
handler = getattr(admin, action.handler, None)
|
|
91
|
+
if not callable(handler):
|
|
92
|
+
raise CabinetRegistrationError(
|
|
93
|
+
f"Cabinet admin {admin.key!r} action {action.path!r} references missing handler "
|
|
94
|
+
f"{action.handler!r}."
|
|
95
|
+
)
|
|
96
|
+
endpoint = self._build_action_endpoint(admin, action, handler)
|
|
97
|
+
router.add_api_route(
|
|
98
|
+
action_public_path(admin, action.path, mount_path),
|
|
99
|
+
endpoint,
|
|
100
|
+
methods=[action.method],
|
|
101
|
+
name=action.name or f"cabinet:{admin.key}:action:{action.path.strip('/')}",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
for page in sorted(admin.pages, key=_page_sort_key):
|
|
105
|
+
router.get(
|
|
106
|
+
page_public_path(admin, page, mount_path),
|
|
107
|
+
name=f"cabinet:{admin.key}:page:{page.key}",
|
|
108
|
+
)(self._build_page_endpoint(admin, page, mount_path, static_mount_path))
|
|
109
|
+
|
|
110
|
+
def _build_module_endpoint(
|
|
111
|
+
self,
|
|
112
|
+
admin: CabinetAdmin,
|
|
113
|
+
mount_path: str,
|
|
114
|
+
static_mount_path: str,
|
|
115
|
+
) -> Callable[[Request], Awaitable[Response]]:
|
|
116
|
+
async def module_page(request: Request) -> Response:
|
|
117
|
+
await self._ensure_allowed(request, admin.permission)
|
|
118
|
+
visible_admins = await self._visible_admins(request)
|
|
119
|
+
sidebar = await self._visible_sidebar(request, admin.sidebar)
|
|
120
|
+
widget_declarations = await self._visible_widgets(request, admin.dashboard_widgets)
|
|
121
|
+
badges = await admin.get_sidebar_badges(request)
|
|
122
|
+
layout = build_layout_map(
|
|
123
|
+
visible_admins,
|
|
124
|
+
mount_path=mount_path,
|
|
125
|
+
static_mount_path=static_mount_path,
|
|
126
|
+
brand_name=self.brand_name,
|
|
127
|
+
active_admin=admin,
|
|
128
|
+
active_path=str(request.url.path),
|
|
129
|
+
sidebar=sidebar,
|
|
130
|
+
sidebar_badges=badges,
|
|
131
|
+
)
|
|
132
|
+
return self.templates.TemplateResponse(
|
|
133
|
+
request=request,
|
|
134
|
+
name="cabinet/module.html",
|
|
135
|
+
context=self._context(
|
|
136
|
+
request,
|
|
137
|
+
layout=layout,
|
|
138
|
+
module=admin,
|
|
139
|
+
module_context=await admin.get_dashboard_context(request),
|
|
140
|
+
widgets=await resolve_widgets(admin, widget_declarations, request),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return module_page
|
|
145
|
+
|
|
146
|
+
def _build_page_endpoint(
|
|
147
|
+
self,
|
|
148
|
+
admin: CabinetAdmin,
|
|
149
|
+
declaration: CabinetPage,
|
|
150
|
+
mount_path: str,
|
|
151
|
+
static_mount_path: str,
|
|
152
|
+
) -> Callable[[Request], Awaitable[Response]]:
|
|
153
|
+
async def page_endpoint(request: Request) -> Response:
|
|
154
|
+
await self._ensure_allowed(request, admin.permission)
|
|
155
|
+
await self._ensure_allowed(request, declaration.permission)
|
|
156
|
+
visible_admins = await self._visible_admins(request)
|
|
157
|
+
sidebar = await self._visible_sidebar(request, admin.sidebar)
|
|
158
|
+
layout = build_layout_map(
|
|
159
|
+
visible_admins,
|
|
160
|
+
mount_path=mount_path,
|
|
161
|
+
static_mount_path=static_mount_path,
|
|
162
|
+
brand_name=self.brand_name,
|
|
163
|
+
active_admin=admin,
|
|
164
|
+
active_path=str(request.url.path),
|
|
165
|
+
sidebar=sidebar,
|
|
166
|
+
sidebar_badges=await admin.get_sidebar_badges(request),
|
|
167
|
+
title=declaration.label,
|
|
168
|
+
)
|
|
169
|
+
page = await resolve_page(admin, declaration, request)
|
|
170
|
+
template = declaration.template or f"cabinet/pages/{declaration.kind}.html"
|
|
171
|
+
return self.templates.TemplateResponse(
|
|
172
|
+
request=request,
|
|
173
|
+
name=template,
|
|
174
|
+
context=self._context(
|
|
175
|
+
request,
|
|
176
|
+
layout=layout,
|
|
177
|
+
module=admin,
|
|
178
|
+
declaration=declaration,
|
|
179
|
+
page=page,
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return page_endpoint
|
|
184
|
+
|
|
185
|
+
def _build_action_endpoint(
|
|
186
|
+
self,
|
|
187
|
+
admin: CabinetAdmin,
|
|
188
|
+
action: ActionRoute,
|
|
189
|
+
handler: Callable[[Request], Awaitable[Response]],
|
|
190
|
+
) -> Callable[[Request], Awaitable[Response]]:
|
|
191
|
+
async def action_endpoint(request: Request) -> Response:
|
|
192
|
+
await self._ensure_allowed(request, admin.permission)
|
|
193
|
+
await self._ensure_allowed(request, action.permission)
|
|
194
|
+
return await handler(request)
|
|
195
|
+
|
|
196
|
+
return action_endpoint
|
|
197
|
+
|
|
198
|
+
async def _visible_admins(self, request: Request) -> tuple[CabinetAdmin, ...]:
|
|
199
|
+
result: list[CabinetAdmin] = []
|
|
200
|
+
for admin in self.registry.all():
|
|
201
|
+
if await self._is_allowed(request, admin.permission):
|
|
202
|
+
result.append(admin)
|
|
203
|
+
return tuple(result)
|
|
204
|
+
|
|
205
|
+
async def _visible_sidebar(
|
|
206
|
+
self,
|
|
207
|
+
request: Request,
|
|
208
|
+
items: tuple[SidebarItem, ...],
|
|
209
|
+
) -> list[SidebarItem]:
|
|
210
|
+
result: list[SidebarItem] = []
|
|
211
|
+
for item in items:
|
|
212
|
+
if await self._is_allowed(request, item.permission):
|
|
213
|
+
result.append(item)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
async def _visible_widgets(
|
|
217
|
+
self,
|
|
218
|
+
request: Request,
|
|
219
|
+
widgets: tuple[DashboardWidget, ...],
|
|
220
|
+
) -> tuple[DashboardWidget, ...]:
|
|
221
|
+
result: list[DashboardWidget] = []
|
|
222
|
+
for widget in widgets:
|
|
223
|
+
if await self._is_allowed(request, widget.permission):
|
|
224
|
+
result.append(widget)
|
|
225
|
+
return tuple(result)
|
|
226
|
+
|
|
227
|
+
async def _is_allowed(self, request: Request, permission: str | None) -> bool:
|
|
228
|
+
return permission is None or await self.permission_provider.can(request, permission)
|
|
229
|
+
|
|
230
|
+
async def _ensure_allowed(self, request: Request, permission: str | None) -> None:
|
|
231
|
+
if not await self._is_allowed(request, permission):
|
|
232
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cabinet permission denied.")
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _context(request: Request, **values: object) -> dict[str, object]:
|
|
236
|
+
return {
|
|
237
|
+
"request": request,
|
|
238
|
+
"csrf_token": getattr(request.state, "csrf_token", ""),
|
|
239
|
+
**values,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _page_sort_key(page: CabinetPage) -> tuple[int, int, str]:
|
|
244
|
+
return (page.path.count("{"), page.order, page.key)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _build_templates(
|
|
248
|
+
templates: Jinja2Templates | None,
|
|
249
|
+
template_directories: Sequence[str | Path],
|
|
250
|
+
) -> Jinja2Templates:
|
|
251
|
+
active = templates or Jinja2Templates(directory=str(_PACKAGE_TEMPLATE_DIR))
|
|
252
|
+
loaders: list[BaseLoader] = [
|
|
253
|
+
FileSystemLoader(str(Path(directory))) for directory in template_directories
|
|
254
|
+
]
|
|
255
|
+
if templates is not None and templates.env.loader is not None:
|
|
256
|
+
loaders.append(templates.env.loader)
|
|
257
|
+
loaders.append(FileSystemLoader(str(_PACKAGE_TEMPLATE_DIR)))
|
|
258
|
+
active.env.loader = ChoiceLoader(loaders)
|
|
259
|
+
return active
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
cabinet_site = CabinetSite()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
--fc-bg: #f4f6f8;
|
|
4
|
+
--fc-surface: #ffffff;
|
|
5
|
+
--fc-text: #18202a;
|
|
6
|
+
--fc-muted: #657180;
|
|
7
|
+
--fc-border: #dfe4ea;
|
|
8
|
+
--fc-primary: #2952cc;
|
|
9
|
+
--fc-primary-soft: #edf2ff;
|
|
10
|
+
--fc-danger: #b42318;
|
|
11
|
+
--fc-radius: 12px;
|
|
12
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body { margin: 0; color: var(--fc-text); background: var(--fc-bg); }
|
|
17
|
+
a { color: var(--fc-primary); text-decoration: none; }
|
|
18
|
+
a:hover { text-decoration: underline; }
|
|
19
|
+
|
|
20
|
+
.fc-shell { min-height: 100vh; }
|
|
21
|
+
.fc-topbar {
|
|
22
|
+
min-height: 64px;
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 32px;
|
|
26
|
+
padding: 0 24px;
|
|
27
|
+
color: #fff;
|
|
28
|
+
background: #111827;
|
|
29
|
+
}
|
|
30
|
+
.fc-brand { color: #fff; font-weight: 750; letter-spacing: .01em; }
|
|
31
|
+
.fc-module-nav, .fc-nav-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
32
|
+
.fc-nav-group__label { color: #9ca3af; margin-right: 4px; font-size: .75rem; text-transform: uppercase; }
|
|
33
|
+
.fc-nav-link { color: #d1d5db; padding: 9px 12px; border-radius: 8px; }
|
|
34
|
+
.fc-nav-link:hover, .fc-nav-link.is-active { color: #fff; background: #263244; text-decoration: none; }
|
|
35
|
+
.fc-workspace { display: grid; grid-template-columns: minmax(210px, 260px) 1fr; min-height: calc(100vh - 64px); }
|
|
36
|
+
.fc-sidebar { padding: 20px 14px; border-right: 1px solid var(--fc-border); background: var(--fc-surface); }
|
|
37
|
+
.fc-sidebar-link { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-radius: 8px; color: var(--fc-text); }
|
|
38
|
+
.fc-sidebar-link:hover, .fc-sidebar-link.is-active { background: var(--fc-primary-soft); text-decoration: none; }
|
|
39
|
+
.fc-badge { min-width: 22px; padding: 2px 7px; border-radius: 999px; text-align: center; background: #e5e7eb; font-size: .75rem; }
|
|
40
|
+
.fc-main { width: 100%; max-width: 1440px; padding: 32px; }
|
|
41
|
+
.fc-page-header { display: flex; justify-content: space-between; align-items: end; gap: 16px; margin-bottom: 24px; }
|
|
42
|
+
.fc-page-header h1 { margin: 4px 0 0; font-size: clamp(1.7rem, 3vw, 2.35rem); }
|
|
43
|
+
.fc-eyebrow { margin: 0; color: var(--fc-primary); font-size: .75rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
|
44
|
+
.fc-muted { color: var(--fc-muted); }
|
|
45
|
+
.fc-card { padding: 20px; margin-bottom: 20px; border: 1px solid var(--fc-border); border-radius: var(--fc-radius); background: var(--fc-surface); box-shadow: 0 1px 2px rgb(15 23 42 / 4%); }
|
|
46
|
+
.fc-card-title { margin: 0 0 16px; font-size: 1rem; }
|
|
47
|
+
.fc-widget-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
|
48
|
+
.fc-widget { margin: 0; }
|
|
49
|
+
.fc-span-1 { grid-column: span 1; }
|
|
50
|
+
.fc-span-2 { grid-column: span 2; }
|
|
51
|
+
.fc-span-3 { grid-column: span 3; }
|
|
52
|
+
.fc-span-4 { grid-column: span 4; }
|
|
53
|
+
.fc-metric { display: flex; justify-content: space-between; gap: 16px; }
|
|
54
|
+
.fc-metric-value { margin: 4px 0 0; font-size: 2rem; font-weight: 750; }
|
|
55
|
+
.fc-metric-icon { font-size: 2rem; }
|
|
56
|
+
.fc-trend { color: #067647; font-size: .875rem; }
|
|
57
|
+
.fc-table-wrap { width: 100%; overflow-x: auto; }
|
|
58
|
+
.fc-table { width: 100%; border-collapse: collapse; }
|
|
59
|
+
.fc-table th, .fc-table td { padding: 12px 10px; border-bottom: 1px solid var(--fc-border); text-align: left; vertical-align: top; }
|
|
60
|
+
.fc-table th { color: var(--fc-muted); font-size: .75rem; letter-spacing: .04em; text-transform: uppercase; }
|
|
61
|
+
.fc-table .is-center { text-align: center; }
|
|
62
|
+
.fc-table .is-right { text-align: right; }
|
|
63
|
+
.fc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
64
|
+
.fc-actions form { margin: 0; }
|
|
65
|
+
.fc-button {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: center;
|
|
69
|
+
min-height: 38px;
|
|
70
|
+
padding: 8px 13px;
|
|
71
|
+
border: 1px solid var(--fc-border);
|
|
72
|
+
border-radius: 8px;
|
|
73
|
+
color: var(--fc-text);
|
|
74
|
+
background: var(--fc-surface);
|
|
75
|
+
font: inherit;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
.fc-button:hover { border-color: #aab4c0; text-decoration: none; }
|
|
79
|
+
.fc-button.is-primary { color: #fff; border-color: var(--fc-primary); background: var(--fc-primary); }
|
|
80
|
+
.fc-button.is-danger { color: #fff; border-color: var(--fc-danger); background: var(--fc-danger); }
|
|
81
|
+
.fc-empty { padding: 32px; border: 1px dashed #b8c1cc; border-radius: var(--fc-radius); color: var(--fc-muted); text-align: center; }
|
|
82
|
+
.fc-filters { display: flex; align-items: end; gap: 12px; flex-wrap: wrap; }
|
|
83
|
+
.fc-filters label, .fc-field { display: grid; gap: 7px; font-weight: 600; }
|
|
84
|
+
.fc-form { display: grid; gap: 18px; max-width: 760px; }
|
|
85
|
+
input, select, textarea {
|
|
86
|
+
width: 100%;
|
|
87
|
+
min-height: 40px;
|
|
88
|
+
padding: 9px 11px;
|
|
89
|
+
border: 1px solid #b8c1cc;
|
|
90
|
+
border-radius: 8px;
|
|
91
|
+
color: var(--fc-text);
|
|
92
|
+
background: #fff;
|
|
93
|
+
font: inherit;
|
|
94
|
+
}
|
|
95
|
+
input[type="checkbox"] { width: 18px; min-height: 18px; }
|
|
96
|
+
textarea { min-height: 120px; resize: vertical; }
|
|
97
|
+
.fc-error { color: var(--fc-danger); }
|
|
98
|
+
.fc-alert { padding: 12px 14px; border-radius: 8px; background: #eef4ff; }
|
|
99
|
+
.fc-alert.is-error { color: var(--fc-danger); background: #fef3f2; }
|
|
100
|
+
.fc-pagination { display: flex; align-items: center; justify-content: center; gap: 14px; }
|
|
101
|
+
.fc-details { display: grid; gap: 0; margin: 0; }
|
|
102
|
+
.fc-details > div { display: grid; grid-template-columns: minmax(130px, 1fr) 3fr; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--fc-border); }
|
|
103
|
+
.fc-details > div:last-child { border-bottom: 0; }
|
|
104
|
+
.fc-details dt { color: var(--fc-muted); }
|
|
105
|
+
.fc-details dd { margin: 0; }
|
|
106
|
+
.fc-operation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
|
107
|
+
.fc-list { margin: 0; padding-left: 20px; }
|
|
108
|
+
.fc-chart-series dl { display: grid; gap: 8px; }
|
|
109
|
+
.fc-chart-point { display: grid; grid-template-columns: minmax(80px, 1fr) 3fr; align-items: center; gap: 12px; }
|
|
110
|
+
.fc-chart-point dd { position: relative; margin: 0; padding: 5px 8px; border-radius: 5px; background: linear-gradient(90deg, #9db5ff 0 min(calc(var(--fc-value) * 1%), 100%), #edf0f4 0); }
|
|
111
|
+
|
|
112
|
+
@media (max-width: 900px) {
|
|
113
|
+
.fc-topbar { align-items: flex-start; flex-direction: column; gap: 12px; padding-block: 16px; }
|
|
114
|
+
.fc-workspace { display: block; }
|
|
115
|
+
.fc-sidebar { border-right: 0; border-bottom: 1px solid var(--fc-border); }
|
|
116
|
+
.fc-main { padding: 22px 16px; }
|
|
117
|
+
.fc-widget-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
118
|
+
.fc-span-3, .fc-span-4 { grid-column: span 2; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@media (max-width: 560px) {
|
|
122
|
+
.fc-page-header { align-items: flex-start; flex-direction: column; }
|
|
123
|
+
.fc-widget-grid { grid-template-columns: 1fr; }
|
|
124
|
+
.fc-span-1, .fc-span-2, .fc-span-3, .fc-span-4 { grid-column: span 1; }
|
|
125
|
+
.fc-details > div { grid-template-columns: 1fr; gap: 4px; }
|
|
126
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{ layout.title }} · {{ layout.brand_name }}</title>
|
|
7
|
+
<link rel="stylesheet" href="{{ layout.static_mount_path }}/css/cabinet.css?v={{ layout.static_version }}">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="fc-shell">
|
|
11
|
+
{% include "cabinet/includes/topbar.html" %}
|
|
12
|
+
<div class="fc-workspace">
|
|
13
|
+
{% include "cabinet/includes/sidebar.html" %}
|
|
14
|
+
<main class="fc-main" id="main-content">
|
|
15
|
+
{% block content %}{% endblock %}
|
|
16
|
+
</main>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<script src="{{ layout.static_mount_path }}/js/cabinet.js?v={{ layout.static_version }}" defer></script>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{% extends "cabinet/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<header class="fc-page-header">
|
|
4
|
+
<div>
|
|
5
|
+
<p class="fc-eyebrow">Management</p>
|
|
6
|
+
<h1>{{ layout.title }}</h1>
|
|
7
|
+
</div>
|
|
8
|
+
</header>
|
|
9
|
+
{% if modules %}
|
|
10
|
+
<div class="fc-empty">Choose a management module.</div>
|
|
11
|
+
{% else %}
|
|
12
|
+
<div class="fc-empty">No management modules are available.</div>
|
|
13
|
+
{% endif %}
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{% if layout.sidebar %}
|
|
2
|
+
<aside class="fc-sidebar" aria-label="Module navigation">
|
|
3
|
+
<nav>
|
|
4
|
+
{% for item in layout.sidebar %}
|
|
5
|
+
<a
|
|
6
|
+
class="fc-sidebar-link{% if layout.active_path == item.path %} is-active{% endif %}"
|
|
7
|
+
href="{{ item.path }}"
|
|
8
|
+
>
|
|
9
|
+
<span>
|
|
10
|
+
{% if item.icon %}<span aria-hidden="true">{{ item.icon }}</span>{% endif %}
|
|
11
|
+
{{ item.label }}
|
|
12
|
+
</span>
|
|
13
|
+
{% if item.badge_key and item.badge_key in layout.sidebar_badges %}
|
|
14
|
+
<span class="fc-badge">{{ layout.sidebar_badges[item.badge_key] }}</span>
|
|
15
|
+
{% endif %}
|
|
16
|
+
</a>
|
|
17
|
+
{% endfor %}
|
|
18
|
+
</nav>
|
|
19
|
+
</aside>
|
|
20
|
+
{% endif %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<header class="fc-topbar">
|
|
2
|
+
<a class="fc-brand" href="{{ layout.mount_path }}">{{ layout.brand_name }}</a>
|
|
3
|
+
<nav class="fc-module-nav" aria-label="Management modules">
|
|
4
|
+
{% for group in layout.header_groups %}
|
|
5
|
+
<div class="fc-nav-group">
|
|
6
|
+
{% if layout.header_groups|length > 1 %}
|
|
7
|
+
<span class="fc-nav-group__label">{{ group.label }}</span>
|
|
8
|
+
{% endif %}
|
|
9
|
+
{% for item in group.items %}
|
|
10
|
+
<a
|
|
11
|
+
class="fc-nav-link{% if layout.active_module == item.key %} is-active{% endif %}"
|
|
12
|
+
href="{{ item.path }}"
|
|
13
|
+
>
|
|
14
|
+
{% if item.icon %}<span aria-hidden="true">{{ item.icon }}</span>{% endif %}
|
|
15
|
+
{{ item.label }}
|
|
16
|
+
</a>
|
|
17
|
+
{% endfor %}
|
|
18
|
+
</div>
|
|
19
|
+
{% endfor %}
|
|
20
|
+
</nav>
|
|
21
|
+
</header>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{% extends "cabinet/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<header class="fc-page-header">
|
|
4
|
+
<div>
|
|
5
|
+
<p class="fc-eyebrow">Module</p>
|
|
6
|
+
<h1>{{ module.label }}</h1>
|
|
7
|
+
</div>
|
|
8
|
+
</header>
|
|
9
|
+
{% if widgets %}
|
|
10
|
+
<div class="fc-widget-grid">
|
|
11
|
+
{% for widget in widgets %}
|
|
12
|
+
{% include "cabinet/includes/widget_frame.html" %}
|
|
13
|
+
{% endfor %}
|
|
14
|
+
</div>
|
|
15
|
+
{% else %}
|
|
16
|
+
<div class="fc-empty">This module has no dashboard widgets yet.</div>
|
|
17
|
+
{% endif %}
|
|
18
|
+
{% endblock %}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{% extends "cabinet/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<header class="fc-page-header">
|
|
4
|
+
<div>
|
|
5
|
+
<p class="fc-eyebrow">{{ module.label }}</p>
|
|
6
|
+
<h1>{{ page.title }}</h1>
|
|
7
|
+
{% if page.subtitle %}<p class="fc-muted">{{ page.subtitle }}</p>{% endif %}
|
|
8
|
+
</div>
|
|
9
|
+
<div class="fc-actions">
|
|
10
|
+
{% for action in page.actions %}<a class="fc-button {{ action.css_class }}" href="{{ action.url }}">{{ action.label }}</a>{% endfor %}
|
|
11
|
+
</div>
|
|
12
|
+
</header>
|
|
13
|
+
{% if page.back_url %}<p><a href="{{ page.back_url }}">← Back</a></p>{% endif %}
|
|
14
|
+
{% if page.fields %}
|
|
15
|
+
<section class="fc-card">
|
|
16
|
+
<dl class="fc-details">
|
|
17
|
+
{% for field in page.fields %}
|
|
18
|
+
<div>
|
|
19
|
+
<dt>{{ field.label }}</dt>
|
|
20
|
+
<dd>{{ field.value }}</dd>
|
|
21
|
+
</div>
|
|
22
|
+
{% endfor %}
|
|
23
|
+
</dl>
|
|
24
|
+
</section>
|
|
25
|
+
{% endif %}
|
|
26
|
+
{% for section in page.sections %}
|
|
27
|
+
<section class="fc-card">
|
|
28
|
+
<h2 class="fc-card-title">{{ section.title }}</h2>
|
|
29
|
+
<dl class="fc-details">
|
|
30
|
+
{% for field in section.fields %}
|
|
31
|
+
<div>
|
|
32
|
+
<dt>{{ field.label }}</dt>
|
|
33
|
+
<dd>{{ field.value }}</dd>
|
|
34
|
+
</div>
|
|
35
|
+
{% endfor %}
|
|
36
|
+
</dl>
|
|
37
|
+
</section>
|
|
38
|
+
{% endfor %}
|
|
39
|
+
{% endblock %}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{% extends "cabinet/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<header class="fc-page-header">
|
|
4
|
+
<div>
|
|
5
|
+
<p class="fc-eyebrow">{{ module.label }}</p>
|
|
6
|
+
<h1>{{ page.title }}</h1>
|
|
7
|
+
{% if page.subtitle %}<p class="fc-muted">{{ page.subtitle }}</p>{% endif %}
|
|
8
|
+
</div>
|
|
9
|
+
</header>
|
|
10
|
+
<form class="fc-card fc-form" action="{{ page.action_url }}" method="post">
|
|
11
|
+
<input type="hidden" name="_method" value="{{ page.method }}">
|
|
12
|
+
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
13
|
+
{% if page.errors %}
|
|
14
|
+
<div class="fc-alert is-error" role="alert">
|
|
15
|
+
{% for error in page.errors %}<p>{{ error }}</p>{% endfor %}
|
|
16
|
+
</div>
|
|
17
|
+
{% endif %}
|
|
18
|
+
{% for field in page.fields %}
|
|
19
|
+
{% if field.input_type == "hidden" %}
|
|
20
|
+
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}">
|
|
21
|
+
{% else %}
|
|
22
|
+
<label class="fc-field">
|
|
23
|
+
<span>{{ field.label }}{% if field.required %} <span aria-hidden="true">*</span>{% endif %}</span>
|
|
24
|
+
{% if field.input_type == "textarea" %}
|
|
25
|
+
<textarea name="{{ field.name }}" placeholder="{{ field.placeholder }}"{% if field.required %} required{% endif %}{% if field.disabled %} disabled{% endif %}>{{ field.value }}</textarea>
|
|
26
|
+
{% elif field.input_type == "select" %}
|
|
27
|
+
<select name="{{ field.name }}"{% if field.required %} required{% endif %}{% if field.disabled %} disabled{% endif %}>
|
|
28
|
+
{% for choice in field.choices %}
|
|
29
|
+
<option value="{{ choice.value }}"{% if choice.selected %} selected{% endif %}>{{ choice.label }}</option>
|
|
30
|
+
{% endfor %}
|
|
31
|
+
</select>
|
|
32
|
+
{% elif field.input_type == "checkbox" %}
|
|
33
|
+
<input type="checkbox" name="{{ field.name }}" value="1"{% if field.value %} checked{% endif %}{% if field.disabled %} disabled{% endif %}>
|
|
34
|
+
{% else %}
|
|
35
|
+
<input type="{{ field.input_type }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}"{% if field.required %} required{% endif %}{% if field.disabled %} disabled{% endif %}>
|
|
36
|
+
{% endif %}
|
|
37
|
+
{% if field.help_text %}<small class="fc-muted">{{ field.help_text }}</small>{% endif %}
|
|
38
|
+
{% for error in field.errors %}<small class="fc-error">{{ error }}</small>{% endfor %}
|
|
39
|
+
</label>
|
|
40
|
+
{% endif %}
|
|
41
|
+
{% endfor %}
|
|
42
|
+
<div class="fc-actions">
|
|
43
|
+
<button class="fc-button is-primary" type="submit">{{ page.submit_label }}</button>
|
|
44
|
+
{% if page.cancel_url %}<a class="fc-button" href="{{ page.cancel_url }}">{{ page.cancel_label }}</a>{% endif %}
|
|
45
|
+
</div>
|
|
46
|
+
</form>
|
|
47
|
+
{% endblock %}
|