zrb 1.0.0b10__py3-none-any.whl → 1.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 (43) hide show
  1. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/column/add_column_task.py +99 -55
  2. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/column/add_column_util.py +301 -0
  3. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_task.py +24 -1
  4. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +61 -1
  5. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/template/app_template/module/gateway/view/content/my-module/my-entity.html +297 -0
  6. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/template/gateway_subroute.py +24 -0
  7. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/template/navigation_config_file.py +8 -0
  8. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_task.py +8 -0
  9. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +40 -1
  10. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/template/app_template/module/gateway/subroute/my_module.py +2 -0
  11. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/template/navigation_config_file.py +6 -0
  12. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/common/util/parser.py +2 -2
  13. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/common/util/view.py +1 -1
  14. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/config.py +18 -8
  15. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/config/navigation.py +39 -0
  16. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/route.py +52 -11
  17. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/schema/navigation.py +95 -0
  18. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/subroute/auth.py +91 -8
  19. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/util/auth.py +9 -0
  20. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/util/view.py +33 -8
  21. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/content/auth/permission.html +311 -0
  22. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/content/auth/role.html +0 -0
  23. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/content/auth/user.html +0 -0
  24. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/content/error.html +4 -1
  25. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/content/login.html +67 -0
  26. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/content/logout.html +49 -0
  27. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/static/common/util.js +160 -0
  28. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/static/crud/style.css +14 -0
  29. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/static/crud/util.js +94 -0
  30. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/static/default/pico-style.css +23 -0
  31. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/static/default/script.js +44 -0
  32. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/static/default/style.css +102 -0
  33. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/gateway/view/template/default.html +73 -18
  34. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/requirements.txt +1 -1
  35. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/test/test_homepage.py +2 -4
  36. zrb/runner/web_route/refresh_token_api_route.py +1 -1
  37. zrb/runner/web_route/static/refresh-token.template.js +9 -0
  38. zrb/runner/web_route/static/static_route.py +1 -1
  39. zrb/util/load.py +13 -7
  40. {zrb-1.0.0b10.dist-info → zrb-1.1.0.dist-info}/METADATA +2 -2
  41. {zrb-1.0.0b10.dist-info → zrb-1.1.0.dist-info}/RECORD +43 -26
  42. {zrb-1.0.0b10.dist-info → zrb-1.1.0.dist-info}/WHEEL +0 -0
  43. {zrb-1.0.0b10.dist-info → zrb-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,67 @@
1
+ <main class="container">
2
+ <article>
3
+ <h1>Login</h1>
4
+ <form id="login-form">
5
+ <fieldset>
6
+ <label>
7
+ Username
8
+ <input name="username" placeholder="Username" autocomplete="username" required />
9
+ </label>
10
+ <label>
11
+ Password
12
+ <input type="password" name="password" placeholder="Password" autocomplete="current-password" required />
13
+ </label>
14
+ </fieldset>
15
+
16
+ <button type="submit">🔓 Login</button>
17
+ </form>
18
+ <dialog id="alert" class="contrast">
19
+ <article>
20
+ <p id="alert-message"></p>
21
+ <footer>
22
+ <form method="dialog">
23
+ <button>OK</button>
24
+ </form>
25
+ </footer>
26
+ </article>
27
+ </dialog>
28
+ </article>
29
+ </main>
30
+
31
+ <script>
32
+ document.getElementById("login-form").addEventListener("submit", async function(event) {
33
+ event.preventDefault();
34
+
35
+ const form = event.target;
36
+ const formData = new FormData(form);
37
+ const alertBox = document.getElementById("alert");
38
+ const alertMessage = document.getElementById("alert-message");
39
+
40
+ alertBox.classList.add("hidden");
41
+ alertBox.classList.remove("primary", "secondary");
42
+
43
+ try {
44
+ const response = await fetch("/api/v1/user-sessions", {
45
+ method: "POST",
46
+ body: new URLSearchParams(formData), // Convert FormData to URL-encoded format
47
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
48
+ });
49
+
50
+ const result = await response.json();
51
+
52
+ if (!response.ok) {
53
+ throw new Error(result.message || "Login failed");
54
+ }
55
+ // Assume API return UTC + 0
56
+ UTIL.setAccessTokenExpiredAt(result.access_token_expired_at + "Z");
57
+
58
+ alertMessage.textContent = "Login successful! Redirecting...";
59
+ alertBox.showModal();
60
+
61
+ setTimeout(() => window.location.href = "/", 1500);
62
+ } catch (error) {
63
+ alertMessage.textContent = error.message;
64
+ alertBox.showModal();
65
+ }
66
+ });
67
+ </script>
@@ -0,0 +1,49 @@
1
+ <main class="container">
2
+ <article>
3
+ <h1>Logout</h1>
4
+ <p>Are you sure you want to log out?</p>
5
+ <footer>
6
+ <a role="button" class="secondary" href="/">❌ Cancel</a>
7
+ <button id="logout-button" class="button primary">✅ Confirm</button>
8
+ </footer>
9
+ </article>
10
+
11
+ <dialog id="alert" class="contrast">
12
+ <article>
13
+ <p id="alert-message"></p>
14
+ <footer>
15
+ <form method="dialog">
16
+ <button>OK</button>
17
+ </form>
18
+ </footer>
19
+ </article>
20
+ </dialog>
21
+ </main>
22
+
23
+ <script>
24
+ document.getElementById("logout-button").addEventListener("click", async function() {
25
+ const alertBox = document.getElementById("alert");
26
+ const alertMessage = document.getElementById("alert-message");
27
+
28
+ try {
29
+ const response = await fetch("/api/v1/user-sessions", {
30
+ method: "DELETE",
31
+ headers: { "Content-Type": "application/json" },
32
+ credentials: "include", // Include cookies in the request
33
+ });
34
+
35
+ if (!response.ok) {
36
+ throw new Error("Logout failed");
37
+ }
38
+ UTIL.unsetAccessTokenExpiredAt();
39
+
40
+ alertMessage.textContent = "Logged out successfully! Redirecting...";
41
+ alertBox.showModal();
42
+
43
+ setTimeout(() => window.location.href = "/", 1500);
44
+ } catch (error) {
45
+ alertMessage.textContent = error.message;
46
+ alertBox.showModal();
47
+ }
48
+ });
49
+ </script>
@@ -0,0 +1,160 @@
1
+ const UTIL = {
2
+ refreshUrl: "/api/v1/user-sessions",
3
+ refreshInProgress: null, // Holds the ongoing refresh request
4
+
5
+ unsetAccessTokenExpiredAt() {
6
+ localStorage.removeItem("access-token-expired-at");
7
+ },
8
+
9
+ setAccessTokenExpiredAt(expiredAt) {
10
+ localStorage.setItem("access-token-expired-at", expiredAt);
11
+ },
12
+
13
+ getAccessTokenExpiredAt() {
14
+ return localStorage.getItem("access-token-expired-at");
15
+ },
16
+
17
+ isAccessTokenExpired() {
18
+ const timestamp = new Date(this.getAccessTokenExpiredAt()).getTime();
19
+ // If expiredAt is not a valid date, timestamp will be NaN
20
+ if (isNaN(timestamp)) {
21
+ return true; // Consider it expired
22
+ }
23
+ return timestamp <= Date.now();
24
+ },
25
+
26
+ async fetchAPI(input, options = {}) {
27
+ if (this.isAccessTokenExpired()) {
28
+ await this.refreshAccessToken();
29
+ }
30
+ options.credentials ??= "include";
31
+ options.headers ??= {"Content-Type": "application/json"};
32
+ const response = await fetch(input, options);
33
+ if (!response.ok) {
34
+ const responseBody = await this._extractResponseBody(response);
35
+ const errorMessage = this._extractErrorMessage(responseBody);
36
+ if (errorMessage != null) {
37
+ throw new Error(errorMessage);
38
+ }
39
+ throw new Error(`HTTP Error: ${response.status}`);
40
+ }
41
+ return await response.json();
42
+ },
43
+
44
+ async _extractResponseBody(response) {
45
+ try {
46
+ return await response.json();
47
+ } catch(error) {
48
+ return null;
49
+ }
50
+ },
51
+
52
+ _extractErrorMessage(responseBody) {
53
+ if (typeof responseBody === "string") {
54
+ return responseBody;
55
+ }
56
+ if (Array.isArray(responseBody)) {
57
+ return responseBody.map((r => this._extractErrorMessage(r))).join("\n");
58
+ }
59
+ if (responseBody.message) {
60
+ return responseBody.message;
61
+ }
62
+ if (responseBody.msg) {
63
+ return responseBody.msg;
64
+ }
65
+ if (responseBody.detail) {
66
+ return this._extractErrorMessage(responseBody.detail);
67
+ }
68
+ return null;
69
+ },
70
+
71
+ async refreshAccessToken() {
72
+ if (this.refreshInProgress) {
73
+ return this.refreshInProgress; // Return the ongoing promise if already refreshing
74
+ }
75
+ this.refreshInProgress = (async () => {
76
+ try {
77
+ const response = await fetch(this.refreshUrl, {
78
+ method: "PUT",
79
+ headers: { "Content-Type": "application/json" },
80
+ credentials: "include", // Include cookies in the request
81
+ });
82
+ if (!response.ok) {
83
+ if (response.status === 401 || response.status === 403) {
84
+ console.warn("Skipping token refresh, authentication required.");
85
+ throw new Error("Authentication required");
86
+ }
87
+ throw new Error(`HTTP Error: ${response.status}`);
88
+ }
89
+ const result = await response.json();
90
+ // Assume API return UTC + 0
91
+ this.setAccessTokenExpiredAt(result.access_token_expired_at + "Z");
92
+ console.log("Token refreshed successfully");
93
+ } catch (error) {
94
+ console.error("Cannot refresh token", error);
95
+ throw error;
96
+ } finally {
97
+ this.refreshInProgress = null; // Reset flag after completion
98
+ }
99
+ })();
100
+ return this.refreshInProgress;
101
+ },
102
+
103
+ async refreshAccessTokenPeriodically(refreshAccessTokenIntervalSeconds) {
104
+ let shouldRefresh = true;
105
+ while (shouldRefresh) {
106
+ await new Promise(resolve => setTimeout(resolve, refreshAccessTokenIntervalSeconds * 1000));
107
+ try {
108
+ await this.refreshAccessToken();
109
+ } catch (error) {
110
+ if (error.message === "Authentication required") {
111
+ shouldRefresh = false; // Stop refreshing if authentication is required
112
+ }
113
+ }
114
+ }
115
+ },
116
+
117
+ setFormData(form, data) {
118
+ for (const key in data) {
119
+ // Only search within this form for an element with the matching name
120
+ const element = form.querySelector(`[name="${key}"]`);
121
+ if (element) {
122
+ // For checkboxes or radio buttons, update each matching element within the form
123
+ if (element.type === 'checkbox' || element.type === 'radio') {
124
+ const elements = form.querySelectorAll(`[name="${key}"]`);
125
+ elements.forEach(el => {
126
+ el.checked = (el.value === data[key]);
127
+ });
128
+ } else {
129
+ // For other types of inputs, selects, and textareas, simply set the value
130
+ element.value = data[key];
131
+ }
132
+ }
133
+ }
134
+ },
135
+
136
+ clearFormData(form) {
137
+ const elements = form.querySelectorAll("input, textarea, select");
138
+ elements.forEach(element => {
139
+ if (element.type === "checkbox" || element.type === "radio") {
140
+ element.checked = element.defaultChecked; // Restore default checked state
141
+ } else if (element.tagName === "SELECT") {
142
+ element.selectedIndex = 0; // Select the first option by default
143
+ } else {
144
+ element.value = element.defaultValue || ""; // Reset to default value or empty
145
+ }
146
+ });
147
+ },
148
+
149
+
150
+ getFormData(form) {
151
+ const formData = new FormData(form);
152
+ const data = {};
153
+ // Convert FormData to a plain object
154
+ formData.forEach((value, key) => {
155
+ data[key] = value;
156
+ });
157
+ return data;
158
+ },
159
+
160
+ };
@@ -0,0 +1,14 @@
1
+ #crud-pagination {
2
+ display: flex;
3
+ gap: 0.5rem;
4
+ }
5
+
6
+ #crud-table-fieldset {
7
+ display: grid;
8
+ grid-template-columns: 2fr 1fr 1fr
9
+ }
10
+
11
+ #crud-table-container {
12
+ width: 100%;
13
+ overflow-x: auto;
14
+ }
@@ -0,0 +1,94 @@
1
+ const CRUD_UTIL = {
2
+
3
+ renderPagination(paginationComponent, crudState, total, fetchFunction = "fetchRows") {
4
+ const totalPages = Math.ceil(total / crudState.pageSize);
5
+ paginationComponent.innerHTML = "";
6
+ // Ensure left alignment (if not already handled by PicoCSS or external CSS)
7
+ paginationComponent.style.textAlign = "left";
8
+ let paginationHTML = "";
9
+ // Only show "First" and "Previous" if we're not on page 1
10
+ if (crudState.currentPage > 1) {
11
+ paginationHTML += `<button class="secondary" onclick="${fetchFunction}(1)">&laquo;</button>`;
12
+ paginationHTML += `<button class="secondary" onclick="${fetchFunction}(${crudState.currentPage - 1})">&lt;</button>`;
13
+ }
14
+ if (totalPages <= 5) {
15
+ // If total pages are few, simply list them all
16
+ for (let i = 1; i <= totalPages; i++) {
17
+ paginationHTML += `<button class="secondary" onclick="${fetchFunction}(${i})" ${i === crudState.currentPage ? "disabled" : ""}>${i}</button>`;
18
+ }
19
+ } else {
20
+ // Always show first page
21
+ paginationHTML += `<button class="secondary" onclick="${fetchFunction}(1)" ${crudState.currentPage === 1 ? "disabled" : ""}>1</button>`;
22
+ // Determine start and end for the page range around current page
23
+ const start = Math.max(2, crudState.currentPage - 1);
24
+ const end = Math.min(totalPages - 1, crudState.currentPage + 1);
25
+ // Add ellipsis if there's a gap between first page and the start of the range
26
+ if (start > 2) {
27
+ paginationHTML += `<span style="padding: 0 5px;">...</span>`;
28
+ }
29
+ // Render the range around the current page
30
+ for (let i = start; i <= end; i++) {
31
+ paginationHTML += `<button class="secondary" onclick="${fetchFunction}(${i})" ${i === crudState.currentPage ? "disabled" : ""}>${i}</button>`;
32
+ }
33
+ // Add ellipsis if there's a gap between the end of the range and the last page
34
+ if (end < totalPages - 1) {
35
+ paginationHTML += `<span style="padding: 0 5px;">...</span>`;
36
+ }
37
+ // Always show last page
38
+ paginationHTML += `<button class="secondary" onclick="${fetchFunction}(${totalPages})" ${crudState.currentPage === totalPages ? "disabled" : ""}>${totalPages}</button>`;
39
+ }
40
+ // Only show "Next" and "Last" if we're not on the last page
41
+ if (crudState.currentPage < totalPages) {
42
+ paginationHTML += `<button class="secondary" onclick="${fetchFunction}(${crudState.currentPage + 1})">&gt;</button>`;
43
+ paginationHTML += `<button class="secondary" onclick="${fetchFunction}(${totalPages})">&raquo;</button>`;
44
+ }
45
+ paginationComponent.innerHTML = paginationHTML;
46
+ },
47
+
48
+ splitUnescaped(query, delimiter=",") {
49
+ const parts = [];
50
+ let current = "";
51
+ let escaped = false;
52
+ for (let i = 0; i < query.length; i++) {
53
+ const char = query[i];
54
+ if (escaped) {
55
+ current += char;
56
+ escaped = false;
57
+ } else if (char === "\\") {
58
+ escaped = true;
59
+ } else if (char === delimiter) {
60
+ parts.push(current);
61
+ current = "";
62
+ } else {
63
+ current += char;
64
+ }
65
+ }
66
+ if (current != "") {
67
+ parts.push(current);
68
+ }
69
+ return parts;
70
+ },
71
+
72
+ isValidFilterQuery(query) {
73
+ const filterPattern = /^([\w]+):(eq|ne|gt|gte|lt|lte|like|in):(.+)$/;
74
+ const parts = this.splitUnescaped(query);
75
+ return parts.every(part => filterPattern.test(part));
76
+ },
77
+
78
+ getSearchParam(crudState, defaultSearchColumn, apiMode = false) {
79
+ return new URLSearchParams({
80
+ page: crudState.currentPage || 1,
81
+ page_size: crudState.pageSize || 10,
82
+ filter: this._getFilterSearchParamValue(crudState, defaultSearchColumn, apiMode),
83
+ }).toString();
84
+ },
85
+
86
+ _getFilterSearchParamValue(crudState, defaultSearchColumn, apiMode = false) {
87
+ const filter = crudState.filter || "";
88
+ if (!apiMode) {
89
+ return filter;
90
+ }
91
+ return this.isValidFilterQuery(filter) ? filter : `${defaultSearchColumn}:like:%${filter}%`;
92
+ }
93
+
94
+ }
@@ -0,0 +1,23 @@
1
+ h1 {
2
+ color: var(--pico-primary)
3
+ }
4
+
5
+ h2 {
6
+ color: var(--pico-primary)
7
+ }
8
+
9
+ h3 {
10
+ color: var(--pico-primary)
11
+ }
12
+
13
+ h4 {
14
+ color: var(--pico-primary)
15
+ }
16
+
17
+ h5 {
18
+ color: var(--pico-primary)
19
+ }
20
+
21
+ h6 {
22
+ color: var(--pico-primary)
23
+ }
@@ -0,0 +1,44 @@
1
+ // Hamburger menu functionality
2
+ const hamburgerMenu = document.querySelector('.hamburger-menu');
3
+ const layoutContainer = document.querySelector('.layout-container');
4
+
5
+ hamburgerMenu.addEventListener('click', function() {
6
+ layoutContainer.classList.toggle('menu-active');
7
+ });
8
+
9
+ // Close menu when clicking outside
10
+ document.addEventListener('click', function(event) {
11
+ if (!layoutContainer.contains(event.target) && !hamburgerMenu.contains(event.target)) {
12
+ layoutContainer.classList.remove('menu-active');
13
+ }
14
+ });
15
+
16
+ // Theme switcher functionality
17
+ const themeSelect = document.getElementById('theme-select');
18
+
19
+ function setTheme(theme) {
20
+ document.documentElement.setAttribute('data-theme', theme);
21
+ localStorage.setItem('theme', theme);
22
+ }
23
+
24
+ function getSavedTheme() {
25
+ return localStorage.getItem('theme') || 'auto';
26
+ }
27
+
28
+ const savedTheme = getSavedTheme();
29
+ setTheme(savedTheme);
30
+ themeSelect.value = savedTheme;
31
+
32
+ themeSelect.addEventListener('change', (e) => {
33
+ setTheme(e.target.value);
34
+ });
35
+
36
+ function updateAutoTheme() {
37
+ if (getSavedTheme() === 'auto') {
38
+ const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
39
+ document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
40
+ }
41
+ }
42
+
43
+ updateAutoTheme();
44
+ window.matchMedia('(prefers-color-scheme: dark)').addListener(updateAutoTheme);
@@ -0,0 +1,102 @@
1
+ body {
2
+ overflow-x: hidden;
3
+ margin: 0;
4
+ padding: 0;
5
+ min-height: 100vh;
6
+ }
7
+ .layout-container {
8
+ display: flex;
9
+ min-height: 100vh;
10
+ }
11
+ aside {
12
+ position: fixed;
13
+ top: 0;
14
+ left: 0;
15
+ bottom: 0;
16
+ width: 300px;
17
+ padding: 1rem;
18
+ border-right: 1px solid var(--muted-border-color);
19
+ height: 100vh;
20
+ background: var(--background-color);
21
+ display: flex;
22
+ flex-direction: column;
23
+ }
24
+ aside nav {
25
+ flex-grow: 1; /* Pushes theme-switcher to the bottom */
26
+ overflow-y: auto;
27
+ overflow-x: hidden;
28
+ }
29
+ aside ul {
30
+ padding: 0;
31
+ list-style: none;
32
+ }
33
+ aside li {
34
+ margin-bottom: 0.5rem;
35
+ }
36
+ aside li > ul {
37
+ padding-left: 1rem;
38
+ }
39
+ .content-wrapper {
40
+ flex: 1;
41
+ overflow-x: hidden;
42
+ }
43
+ .content {
44
+ padding: 1rem;
45
+ }
46
+ .hamburger-menu {
47
+ display: none;
48
+ background: var(--pico-primary);
49
+ border: none;
50
+ font-size: 1.5rem;
51
+ cursor: pointer;
52
+ position: fixed;
53
+ top: 1rem;
54
+ left: 1rem;
55
+ z-index: 1001;
56
+ color: var(--contrast);
57
+ }
58
+ .theme-switcher {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 0.5rem;
62
+ margin-top: auto;
63
+ }
64
+ .active-link {
65
+ font-weight: bold;
66
+ color: var(--primary);
67
+ }
68
+
69
+ @media (max-width: 768px) {
70
+ .layout-container {
71
+ position: relative;
72
+ left: 0;
73
+ transition: left 0.3s ease;
74
+ }
75
+ .hamburger-menu {
76
+ display: block;
77
+ }
78
+ aside {
79
+ left: -300px;
80
+ top: 0;
81
+ bottom: 0;
82
+ z-index: 1000;
83
+ transition: left 0.3s ease;
84
+ padding-top: 4rem;
85
+ }
86
+ .layout-container.menu-active {
87
+ margin-left: 300px;
88
+ /*left: 300px;*/
89
+ }
90
+ .layout-container.menu-active aside {
91
+ left: 0;
92
+ }
93
+ .content {
94
+ padding-top: 4rem;
95
+ }
96
+ }
97
+
98
+ @media (min-width: 769px) {
99
+ .content-wrapper {
100
+ margin-left: 300px;
101
+ }
102
+ }
@@ -4,31 +4,86 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <meta name="color-scheme" content="light dark">
7
- {% for css_path in partials.css_path_list -%}
7
+ <link rel="stylesheet" href="{{pico_css_path}}">
8
+ <link rel="stylesheet" href="/static/default/pico-style.css">
9
+ <link rel="stylesheet" href="/static/default/style.css">
10
+ {% for css_path in css_path_list -%}
8
11
  <link rel="stylesheet" href="{{css_path}}">
9
12
  {% endfor %}
10
- <link rel="icon" href="{{partials.favicon_path}}" sizes="32x32" type="image/png">
11
- <title>{{partials.title}}</title>
13
+ <link rel="icon" href="{{favicon_path}}" sizes="32x32" type="image/png">
14
+ <title>{{title}}</title>
12
15
  </head>
13
16
  <body>
14
- <header class="container">
15
- <hgroup>
16
- <h1>{{partials.title}}</h1>
17
- <p>{{partials.subtitle}}</p>
17
+ <button class="hamburger-menu" aria-label="Menu">☰</button>
18
+ <div class="layout-container">
19
+ <aside>
18
20
  <nav>
19
21
  <ul>
20
- <li><a href="/">🏠 Home</a></li>
21
- <li><a href="/docs">💻 API Documentation</a></li>
22
- </ul>
23
- <ul>
24
- <li>Hi</li>
22
+ {% for navigation in navigations %}
23
+ {% if navigation|attr("pages") %}
24
+ <li>
25
+ <details {% if navigation.active %}open{% endif %}>
26
+ <summary>{{navigation.caption}}</summary>
27
+ <ul>
28
+ {% for page in navigation.pages %}
29
+ <li><a href="{{page.url}}" class="{{ 'active-link' if page.active else '' }}">{{page.caption}}</a></li>
30
+ {% endfor %}
31
+ </ul>
32
+ </details>
33
+ </li>
34
+ {% else %}
35
+ <li><a href="{{navigation.url}}" class="{{ 'active-link' if navigation.active else '' }}">{{navigation.caption}}</a></li>
36
+ {% endif %}
37
+ {% endfor %}
25
38
  </ul>
26
39
  </nav>
27
- </hgroup>
28
- </header>
29
- {{content}}
40
+ <div class="theme-switcher">
41
+ <label for="theme-select">Theme:</label>
42
+ <select id="theme-select">
43
+ <option value="auto">🌗 Auto</option>
44
+ <option value="light">☀️ Light</option>
45
+ <option value="dark">🌙 Dark</option>
46
+ </select>
47
+ </div>
48
+ </aside>
49
+ <div class="content-wrapper">
50
+ <main class="content">
51
+ <header>
52
+ <hgroup>
53
+ <h1>{{title}}</h1>
54
+ <p>{{subtitle}}</p>
55
+ </hgroup>
56
+ {% if show_user_info %}
57
+ <nav>
58
+ <ul></ul>
59
+ <ul>
60
+ <li>
61
+ {% if current_user is none %}
62
+ <p>Hi Visitor <a href="/login">🔓</a></p>
63
+ {% elif current_user.is_guest %}
64
+ <p>Hi {{current_user.username}} <a href="/login">🔓</a></p>
65
+ {% else %}
66
+ <p>Hi {{current_user.username}} <a href="/logout">🔒</a></p>
67
+ {% endif %}
68
+ </li>
69
+ </ul>
70
+ </nav>
71
+ {% endif %}
72
+ </header>
73
+ <script src="/static/common/util.js"></script>
74
+ {{content}}
75
+ <footer>{{footer}}</footer>
76
+ </main>
77
+ </div>
78
+ </div>
79
+ {% for js_path in js_path_list -%}
80
+ <script src="{{js_path}}"></script>
81
+ {% endfor %}
82
+ <script src="/static/default/script.js"></script>
83
+ <script>
84
+ {% if should_refresh_session %}
85
+ UTIL.refreshAccessTokenPeriodically({{refresh_session_interval_seconds}});
86
+ {% endif %}
87
+ </script>
30
88
  </body>
31
- {% for js_path in partials.js_path_list -%}
32
- <script src="{{js_path}}"></script>
33
- {% endfor %}
34
89
  </html>
@@ -4,7 +4,7 @@ sqlmodel~=0.0.22
4
4
  ulid-py~=1.1.0
5
5
  passlib~=1.7.4
6
6
  Jinja2~=3.1.5
7
- python-jose~=3.3.0
7
+ python-jose~=3.4.0
8
8
  passlib~=1.7.4
9
9
 
10
10
  pytest~=8.3.4