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.
Files changed (39) hide show
  1. codex_fastapi_cabinet-0.1.0.dist-info/METADATA +182 -0
  2. codex_fastapi_cabinet-0.1.0.dist-info/RECORD +39 -0
  3. codex_fastapi_cabinet-0.1.0.dist-info/WHEEL +4 -0
  4. codex_fastapi_cabinet-0.1.0.dist-info/licenses/LICENSE +190 -0
  5. fastapi_cabinet/__init__.py +90 -0
  6. fastapi_cabinet/contracts/__init__.py +86 -0
  7. fastapi_cabinet/contracts/admin.py +50 -0
  8. fastapi_cabinet/contracts/layout.py +22 -0
  9. fastapi_cabinet/contracts/navigation.py +29 -0
  10. fastapi_cabinet/contracts/pages.py +164 -0
  11. fastapi_cabinet/contracts/permissions.py +16 -0
  12. fastapi_cabinet/contracts/providers.py +9 -0
  13. fastapi_cabinet/contracts/widgets.py +102 -0
  14. fastapi_cabinet/exceptions.py +18 -0
  15. fastapi_cabinet/fastapi.py +36 -0
  16. fastapi_cabinet/loader.py +68 -0
  17. fastapi_cabinet/registry.py +31 -0
  18. fastapi_cabinet/rendering/__init__.py +5 -0
  19. fastapi_cabinet/rendering/layout_mapper.py +88 -0
  20. fastapi_cabinet/rendering/page_mapper.py +44 -0
  21. fastapi_cabinet/rendering/widget_mapper.py +59 -0
  22. fastapi_cabinet/runtime.py +58 -0
  23. fastapi_cabinet/site.py +262 -0
  24. fastapi_cabinet/static/css/cabinet.css +126 -0
  25. fastapi_cabinet/static/js/cabinet.js +6 -0
  26. fastapi_cabinet/templates/cabinet/base.html +21 -0
  27. fastapi_cabinet/templates/cabinet/dashboard.html +14 -0
  28. fastapi_cabinet/templates/cabinet/includes/sidebar.html +20 -0
  29. fastapi_cabinet/templates/cabinet/includes/topbar.html +21 -0
  30. fastapi_cabinet/templates/cabinet/includes/widget_frame.html +3 -0
  31. fastapi_cabinet/templates/cabinet/module.html +18 -0
  32. fastapi_cabinet/templates/cabinet/pages/detail.html +39 -0
  33. fastapi_cabinet/templates/cabinet/pages/form.html +47 -0
  34. fastapi_cabinet/templates/cabinet/pages/list.html +81 -0
  35. fastapi_cabinet/templates/cabinet/pages/operation.html +29 -0
  36. fastapi_cabinet/templates/cabinet/widgets/chart.html +20 -0
  37. fastapi_cabinet/templates/cabinet/widgets/list.html +8 -0
  38. fastapi_cabinet/templates/cabinet/widgets/metric.html +9 -0
  39. 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}/")
@@ -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,6 @@
1
+ document.addEventListener("click", (event) => {
2
+ const button = event.target.closest("[data-confirm]");
3
+ if (button && !window.confirm(button.dataset.confirm)) {
4
+ event.preventDefault();
5
+ }
6
+ });
@@ -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,3 @@
1
+ <section class="fc-card fc-widget fc-span-{{ widget.span }}" data-widget="{{ widget.key }}">
2
+ {% include "cabinet/widgets/" ~ widget.kind ~ ".html" %}
3
+ </section>
@@ -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 %}