fse-theme-default 0.3.0

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 (47) hide show
  1. package/README.md +55 -0
  2. package/astro.config.mjs +22 -0
  3. package/package.json +38 -0
  4. package/public/favicon.svg +4 -0
  5. package/src/assets/logo.svg +5 -0
  6. package/src/components/Card.astro +31 -0
  7. package/src/components/Head.astro +9 -0
  8. package/src/components/Header.astro +55 -0
  9. package/src/components/Input.astro +56 -0
  10. package/src/components/Logo.astro +17 -0
  11. package/src/components/Modal.astro +78 -0
  12. package/src/components/NavLink.astro +23 -0
  13. package/src/components/Pagination.astro +81 -0
  14. package/src/components/SearchBar.astro +65 -0
  15. package/src/components/Select.astro +60 -0
  16. package/src/components/Sidebar.astro +207 -0
  17. package/src/components/SidebarLinks.astro +10 -0
  18. package/src/components/Table.astro +157 -0
  19. package/src/components/TableFilters.astro +137 -0
  20. package/src/env.d.ts +2 -0
  21. package/src/layouts/EmailLayout.astro +45 -0
  22. package/src/layouts/Layout.astro +95 -0
  23. package/src/pages/emails/password-reset.astro +39 -0
  24. package/src/pages/emails/verify-email-change.astro +39 -0
  25. package/src/pages/emails/verify.astro +39 -0
  26. package/src/pages/error.astro +26 -0
  27. package/src/pages/forgot-password.astro +67 -0
  28. package/src/pages/fse/form.astro +169 -0
  29. package/src/pages/fse/links.astro +104 -0
  30. package/src/pages/fse/list.astro +166 -0
  31. package/src/pages/fse/public-detail.astro +29 -0
  32. package/src/pages/fse/public-list.astro +54 -0
  33. package/src/pages/index.astro +41 -0
  34. package/src/pages/login.astro +88 -0
  35. package/src/pages/public/error.astro +29 -0
  36. package/src/pages/register-success.astro +31 -0
  37. package/src/pages/register.astro +99 -0
  38. package/src/pages/reset-password.astro +117 -0
  39. package/src/pages/settings.astro +188 -0
  40. package/src/pages/user.astro +65 -0
  41. package/src/pages/users.astro +52 -0
  42. package/src/styles/global.css +11 -0
  43. package/src/styles/theme.css +408 -0
  44. package/src/types/pages.ts +58 -0
  45. package/src/types.ts +154 -0
  46. package/theme.json +5 -0
  47. package/tsconfig.json +5 -0
@@ -0,0 +1,207 @@
1
+ ---
2
+ /**
3
+ * The app shell's navigation. Everything is data-driven: the framework
4
+ * injects `nav` (the admin page of every model the user may read) and
5
+ * `user`; app-specific links come from the SidebarLinks extension point.
6
+ */
7
+ import { ssr } from "fse-ssr/ssr";
8
+ import Logo from "./Logo.astro";
9
+ import NavLink from "./NavLink.astro";
10
+ import SidebarLinks from "./SidebarLinks.astro";
11
+ import type { ShellContext } from "../types";
12
+
13
+ const { t, nav, user, lang_prefix } = ssr<ShellContext>();
14
+ ---
15
+
16
+ <div
17
+ id="sidebar-backdrop"
18
+ class="fixed inset-0 z-30 bg-secondary-900/40 backdrop-blur-xs lg:hidden hidden opacity-0 transition-opacity duration-300"
19
+ >
20
+ </div>
21
+
22
+ <aside
23
+ id="nav-menu-1"
24
+ aria-label="Side navigation"
25
+ class="fixed lg:sticky top-0 bottom-0 h-dvh left-0 z-40 flex flex-col bg-white border-r border-secondary-200 w-64 transform -translate-x-full lg:translate-x-0 transition-all duration-300 ease-in-out overflow-hidden"
26
+ transition:name="sidebar"
27
+ transition:persist
28
+ transition:animate="none"
29
+ >
30
+ <div id="sidebar-header" class="relative flex items-center justify-center h-16 shrink-0 px-2">
31
+ <a
32
+ aria-label="Home"
33
+ class="sidebar-full-logo flex items-center gap-2 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-primary-500 rounded-md"
34
+ href={`${lang_prefix}/`}
35
+ >
36
+ <Logo class="h-9 w-auto" />
37
+ </a>
38
+
39
+ <button
40
+ id="toggle-sidebar-mini"
41
+ class="hidden lg:flex absolute right-2 p-2 text-secondary-500 hover:text-secondary-800 hover:bg-secondary-100 rounded-lg transition-colors focus:outline-hidden"
42
+ aria-label="Toggle sidebar"
43
+ >
44
+ <svg class="sidebar-icon-collapse" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
45
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><path d="M9 3v18"></path><path d="m16 15-3-3 3-3"></path>
46
+ </svg>
47
+ <svg class="sidebar-icon-expand hidden" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
48
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><path d="M9 3v18"></path><path d="m14 9 3 3-3 3"></path>
49
+ </svg>
50
+ </button>
51
+
52
+ <button
53
+ id="close-sidebar"
54
+ class="lg:hidden absolute right-2 p-2 text-secondary-500 hover:text-secondary-900 hover:bg-secondary-100 rounded-lg transition-colors focus:outline-hidden"
55
+ aria-label="Close sidebar"
56
+ >
57
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
58
+ <line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
59
+ </svg>
60
+ </button>
61
+ </div>
62
+
63
+ <nav aria-label="Main" class="flex-1 overflow-y-auto px-3 py-3 space-y-0.5">
64
+ <NavLink href={`${lang_prefix}/`} label={t.nav.home ?? "Home"}>
65
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
66
+ <path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"></path>
67
+ </svg>
68
+ </NavLink>
69
+
70
+ <SidebarLinks />
71
+
72
+ {nav.map((item) => (
73
+ <NavLink href={`${lang_prefix}${item.href}`} label={t.models[item.table].title ?? item.table}>
74
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
75
+ <rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M3 9h18M9 21V9"></path>
76
+ </svg>
77
+ </NavLink>
78
+ ))}
79
+ </nav>
80
+
81
+ <div class="border-t border-secondary-200 px-3 py-3 space-y-0.5 shrink-0">
82
+ <button
83
+ id="toggle-theme"
84
+ type="button"
85
+ class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-secondary-700 hover:bg-secondary-100 hover:text-secondary-900"
86
+ aria-label="Toggle dark mode"
87
+ >
88
+ <svg class="shrink-0 block dark:hidden" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
89
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
90
+ </svg>
91
+ <svg class="shrink-0 hidden dark:block" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
92
+ <circle cx="12" cy="12" r="4"></circle><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path>
93
+ </svg>
94
+ <span class="sidebar-label truncate">
95
+ <span class="block dark:hidden">{t.nav.dark_mode ?? "Dark mode"}</span>
96
+ <span class="hidden dark:block">{t.nav.light_mode ?? "Light mode"}</span>
97
+ </span>
98
+ </button>
99
+
100
+ {user && user.can_read_users && (
101
+ <NavLink href={`${lang_prefix}/users`} label={t.nav.users ?? "Users"}>
102
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
103
+ <circle cx="12" cy="6" r="4"></circle><path d="M20 17.5c0 2.485 0 4.5-8 4.5s-8-2.015-8-4.5S7.582 13 12 13s8 2.015 8 4.5Z"></path>
104
+ </svg>
105
+ </NavLink>
106
+ )}
107
+
108
+ {user && (
109
+ <NavLink href={`${lang_prefix}/settings`} label={t.nav.settings ?? "Settings"}>
110
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
111
+ <path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 0 0-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 0 0-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><circle cx="12" cy="12" r="3"></circle>
112
+ </svg>
113
+ </NavLink>
114
+ )}
115
+
116
+ {user && (
117
+ <form action="/logout" method="POST" data-astro-reload>
118
+ <button
119
+ class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-secondary-700 hover:bg-secondary-100 hover:text-secondary-900"
120
+ type="submit"
121
+ >
122
+ <svg class="shrink-0" xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
123
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 9l-3 3m0 0l3 3m-3-3h7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
124
+ </svg>
125
+ <span class="sidebar-label font-medium truncate">{t.nav.logout ?? "Logout"}</span>
126
+ </button>
127
+ </form>
128
+ )}
129
+
130
+ {!user && (
131
+ <NavLink href={`${lang_prefix}/login`} label={t.nav.login ?? "Login"}>
132
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
133
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"></path>
134
+ </svg>
135
+ </NavLink>
136
+ )}
137
+ </div>
138
+ </aside>
139
+
140
+ <script>
141
+ function setActiveLink() {
142
+ const currentPath = window.location.pathname;
143
+ document.querySelectorAll<HTMLAnchorElement>("[data-nav-link]").forEach((link) => {
144
+ const href = link.getAttribute("href");
145
+ if (!href) return;
146
+ const root = href.replace(/\/$/, "") === "";
147
+ const active = root ? currentPath === href : currentPath.startsWith(href);
148
+ if (active) link.setAttribute("aria-current", "page");
149
+ else link.removeAttribute("aria-current");
150
+ });
151
+ }
152
+
153
+ function openSidebar() {
154
+ const sidebar = document.getElementById("nav-menu-1");
155
+ const backdrop = document.getElementById("sidebar-backdrop");
156
+ sidebar?.classList.remove("-translate-x-full");
157
+ backdrop?.classList.remove("hidden");
158
+ setTimeout(() => backdrop?.classList.remove("opacity-0"), 10);
159
+ document.body.style.overflow = "hidden";
160
+ }
161
+
162
+ function closeSidebar() {
163
+ const sidebar = document.getElementById("nav-menu-1");
164
+ const backdrop = document.getElementById("sidebar-backdrop");
165
+ sidebar?.classList.add("-translate-x-full");
166
+ backdrop?.classList.add("opacity-0");
167
+ setTimeout(() => backdrop?.classList.add("hidden"), 300);
168
+ document.body.style.overflow = "";
169
+ }
170
+
171
+ document.addEventListener("open-sidebar", openSidebar);
172
+ document.addEventListener("astro:before-preparation", closeSidebar);
173
+
174
+ function bindOnce(el: HTMLElement | null, key: string, handler: () => void) {
175
+ if (!el || el.dataset.bound === key) return;
176
+ el.dataset.bound = key;
177
+ el.addEventListener("click", handler);
178
+ }
179
+
180
+ function setupSidebar() {
181
+ const root = document.documentElement;
182
+ const miniToggle = document.getElementById("toggle-sidebar-mini");
183
+
184
+ const syncMiniIcons = (isMini: boolean) => {
185
+ miniToggle?.querySelector(".sidebar-icon-collapse")?.classList.toggle("hidden", isMini);
186
+ miniToggle?.querySelector(".sidebar-icon-expand")?.classList.toggle("hidden", !isMini);
187
+ };
188
+ syncMiniIcons(root.classList.contains("sidebar-mini"));
189
+
190
+ bindOnce(miniToggle, "mini", () => {
191
+ const isMini = root.classList.toggle("sidebar-mini");
192
+ localStorage.setItem("sidebar-mini", isMini ? "1" : "0");
193
+ syncMiniIcons(isMini);
194
+ });
195
+ bindOnce(document.getElementById("close-sidebar"), "close", closeSidebar);
196
+ bindOnce(document.getElementById("sidebar-backdrop"), "close", closeSidebar);
197
+ bindOnce(document.getElementById("toggle-theme"), "theme", () => {
198
+ const isDark = root.classList.toggle("dark");
199
+ localStorage.setItem("theme", isDark ? "dark" : "light");
200
+ });
201
+ }
202
+
203
+ document.addEventListener("astro:page-load", () => {
204
+ setActiveLink();
205
+ setupSidebar();
206
+ });
207
+ </script>
@@ -0,0 +1,10 @@
1
+ ---
2
+ /**
3
+ * Extension point: app-specific sidebar entries, rendered between "Home"
4
+ * and the generated model links. Empty in the default theme — a child theme
5
+ * overrides this file (same path under its src/) to add its own links:
6
+ *
7
+ * import NavLink from "@parent/components/NavLink.astro";
8
+ * <NavLink href="/products" label={t.nav.products}>…icon…</NavLink>
9
+ */
10
+ ---
@@ -0,0 +1,157 @@
1
+ ---
2
+ import { ssr } from "fse-ssr/ssr";
3
+ import type { SsrList } from "fse-ssr/ssr";
4
+ import type { TableHeader } from "../types/pages";
5
+ import Modal from "./Modal.astro";
6
+
7
+ interface Props {
8
+ /** Column config; page-static, so branching on `format` happens at build time. */
9
+ headers: TableHeader[];
10
+ /** Server-side rows; each column renders `row[header.key]`. */
11
+ rows: SsrList<any>;
12
+ }
13
+
14
+ const { headers, rows } = Astro.props;
15
+ const { t } = ssr();
16
+ // Locale lookup with an SSR value as the key (e.g. the row's role).
17
+ const roleLabels = t.roles as Record<string, string>;
18
+
19
+ // Unique per Table instance so multiple tables on one page don't collide on
20
+ // the shared delete-confirmation dialog.
21
+ const modalId = `table-delete-modal-${crypto.randomUUID()}`;
22
+ ---
23
+
24
+ <div class="w-full overflow-x-auto">
25
+ <table class="w-full text-left border-collapse" cellspacing="0">
26
+ <thead class="hidden md:table-header-group">
27
+ <tr class="bg-secondary-50">
28
+ {
29
+ headers.map((h) => (
30
+ <th
31
+ scope="col"
32
+ class="h-11 px-4 text-xs font-semibold uppercase tracking-wide border-b border-secondary-200 text-secondary-500"
33
+ >
34
+ {h.title}
35
+ </th>
36
+ ))
37
+ }
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ {
42
+ rows.map((row) => (
43
+ <tr class="flex flex-col md:table-row transition-colors hover:bg-secondary-50 border-b border-secondary-100 last:border-b-0 p-4 md:p-0 gap-1.5 md:gap-0">
44
+ {headers.map((h) => (
45
+ <td class="text-sm md:border-t border-secondary-100 relative">
46
+ {h.format === "number" ? (
47
+ <div class="px-4 py-2.5 flex items-center justify-between md:justify-start gap-4">
48
+ <span class="md:hidden text-xs font-medium text-secondary-400 uppercase tracking-wide">
49
+ {h.title}
50
+ </span>
51
+ <input
52
+ type="number"
53
+ value={row[h.key]}
54
+ class="w-16 px-2.5 py-1.5 text-sm border border-secondary-300 rounded-lg focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-hidden"
55
+ />
56
+ </div>
57
+ ) : h.format === "delete" ? (
58
+ <div class="px-4 py-2.5 flex items-center justify-between md:justify-start gap-4">
59
+ <span class="md:hidden text-xs font-medium text-secondary-400 uppercase tracking-wide">
60
+ {h.title}
61
+ </span>
62
+ <button
63
+ type="button"
64
+ class="p-1.5 text-secondary-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors table-delete-trigger"
65
+ data-modal-id={modalId}
66
+ data-url={row.delete_url}
67
+ data-error-msg={t.table.delete_error ?? "Error occurred"}
68
+ >
69
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
70
+ <polyline points="3 6 5 6 21 6" />
71
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
72
+ <path d="M10 11v6m4-6v6" />
73
+ <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
74
+ </svg>
75
+ </button>
76
+ </div>
77
+ ) : (
78
+ <a
79
+ href={row.link}
80
+ class="px-4 py-2.5 flex items-center justify-between md:justify-start gap-4 h-full text-secondary-800 hover:text-secondary-900"
81
+ >
82
+ <span class="md:hidden text-xs font-medium text-secondary-400 uppercase tracking-wide">
83
+ {h.title}
84
+ </span>
85
+ <div class="flex-1 md:flex-none text-right md:text-left">
86
+ {h.format === "image" ? (
87
+ row[h.key] ? (
88
+ <img
89
+ src={row[h.key]}
90
+ alt="Thumbnail"
91
+ class="w-10 h-10 object-cover rounded-lg ml-auto md:ml-0"
92
+ />
93
+ ) : (
94
+ <div class="w-10 h-10 bg-secondary-100 rounded-lg flex items-center justify-center text-secondary-400 text-xs ml-auto md:ml-0">
95
+ —
96
+ </div>
97
+ )
98
+ ) : h.key === "role" ? (
99
+ roleLabels[row[h.key]] ?? row[h.key]
100
+ ) : (
101
+ row[h.key]
102
+ )}
103
+ </div>
104
+ </a>
105
+ )}
106
+ </td>
107
+ ))}
108
+ </tr>
109
+ ))
110
+ }
111
+ </tbody>
112
+ </table>
113
+ </div>
114
+
115
+ <!-- Shared delete-confirmation dialog; each delete button fills in its target. -->
116
+ <Modal id={modalId} title={t.table.delete_title}>
117
+ <p class="text-sm text-secondary-600 mb-5">{t.table.delete_confirm}</p>
118
+ <div class="flex justify-end gap-2">
119
+ <button type="button" class="btn-secondary modal-close-btn" data-modal-id={modalId}>
120
+ {t.table.cancel}
121
+ </button>
122
+ <button type="button" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors table-delete-confirm">
123
+ {t.table.delete_btn}
124
+ </button>
125
+ </div>
126
+ </Modal>
127
+
128
+ <script>
129
+ // Opening/closing and backdrop dismissal are handled globally by Modal.astro.
130
+ document.addEventListener("click", (e) => {
131
+ const target = e.target as HTMLElement;
132
+
133
+ const trigger = target.closest(".table-delete-trigger");
134
+ if (trigger) {
135
+ const modalId = trigger.getAttribute("data-modal-id");
136
+ const modal = modalId ? (document.getElementById(modalId) as HTMLDialogElement | null) : null;
137
+ if (modal) {
138
+ const confirmBtn = modal.querySelector(".table-delete-confirm");
139
+ confirmBtn?.setAttribute("data-url", trigger.getAttribute("data-url") || "");
140
+ confirmBtn?.setAttribute("data-error-msg", trigger.getAttribute("data-error-msg") || "");
141
+ if (!modal.open) modal.showModal();
142
+ }
143
+ }
144
+
145
+ const confirmBtn = target.closest(".table-delete-confirm");
146
+ if (confirmBtn) {
147
+ const url = confirmBtn.getAttribute("data-url");
148
+ const errorMsg = confirmBtn.getAttribute("data-error-msg") || "Error occurred";
149
+ if (url) {
150
+ fetch(url, { method: "DELETE" }).then((r) => {
151
+ if (r.ok) window.location.reload();
152
+ else alert(errorMsg);
153
+ });
154
+ }
155
+ }
156
+ });
157
+ </script>
@@ -0,0 +1,137 @@
1
+ ---
2
+ import { ssr } from "fse-ssr/ssr";
3
+
4
+ interface Props {
5
+ searchPlaceholder?: string;
6
+ card?: boolean;
7
+ }
8
+
9
+ const { t, search } = ssr<{ search?: string }>();
10
+
11
+ const { searchPlaceholder = "Search...", card = false } = Astro.props;
12
+ const hasExtraFilters = Astro.slots.has("default");
13
+ ---
14
+
15
+ <div
16
+ class={card
17
+ ? "bg-white rounded-xl border border-secondary-200 overflow-hidden mb-6"
18
+ : ""}
19
+ >
20
+ <form method="GET">
21
+ <!-- Main row -->
22
+ <div class="flex flex-wrap items-center gap-2 px-4 py-3 border-b border-secondary-200 bg-white">
23
+ <div class="relative flex-1 min-w-48">
24
+ <input
25
+ type="text"
26
+ name="search"
27
+ value={search ?? ""}
28
+ placeholder={searchPlaceholder}
29
+ class="w-full h-9 px-3.5 pr-9 text-sm border border-secondary-200 rounded-lg outline-hidden focus:border-primary-500 focus:ring-1 focus:ring-primary-500 bg-white text-secondary-900"
30
+ />
31
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 pointer-events-none">
32
+ <circle cx="11" cy="11" r="8"></circle>
33
+ <path d="m21 21-4.35-4.35"></path>
34
+ </svg>
35
+ </div>
36
+
37
+ <button type="submit" class="btn-primary h-9 py-0">
38
+ {t.filters.search ?? "Suchen"}
39
+ </button>
40
+
41
+ {
42
+ hasExtraFilters && (
43
+ <button
44
+ type="button"
45
+ id="filters-toggle"
46
+ class="flex items-center gap-1.5 h-9 px-3 text-sm border border-secondary-200 rounded-lg hover:bg-secondary-100 transition-colors text-secondary-700 whitespace-nowrap"
47
+ >
48
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
49
+ <line x1="4" y1="6" x2="20" y2="6" />
50
+ <line x1="8" y1="12" x2="16" y2="12" />
51
+ <line x1="11" y1="18" x2="13" y2="18" />
52
+ </svg>
53
+ {t.filters.label ?? "Filter"}
54
+ <span
55
+ id="filters-badge"
56
+ class="hidden min-w-[18px] h-[18px] px-1 rounded-full bg-primary-600 text-white text-[10px] font-bold items-center justify-center leading-none"
57
+ />
58
+ </button>
59
+ )
60
+ }
61
+
62
+ <a
63
+ href="?"
64
+ id="filters-clear"
65
+ class="h-9 px-4 hidden items-center text-sm text-secondary-600 border border-secondary-200 rounded-lg hover:bg-secondary-100 bg-white transition-colors whitespace-nowrap"
66
+ >
67
+ {t.filters.clear ?? "Zurücksetzen"}
68
+ </a>
69
+ </div>
70
+
71
+ <!-- Collapsible extra-filters panel -->
72
+ {
73
+ hasExtraFilters && (
74
+ <div
75
+ id="filters-panel"
76
+ class="hidden flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 px-4 py-3 border-b border-secondary-200"
77
+ >
78
+ <slot />
79
+ <button type="submit" class="btn-primary w-full sm:w-auto h-9 py-0">
80
+ {t.filters.apply ?? "Anwenden"}
81
+ </button>
82
+ </div>
83
+ )
84
+ }
85
+ </form>
86
+ </div>
87
+
88
+ <script>
89
+ function setupFilters() {
90
+ const toggle = document.getElementById("filters-toggle");
91
+ const panel = document.getElementById("filters-panel");
92
+ const badge = document.getElementById("filters-badge");
93
+ const clearBtn = document.getElementById("filters-clear");
94
+
95
+ const params = new URLSearchParams(window.location.search);
96
+ params.delete("page");
97
+
98
+ const extraParams = new URLSearchParams(params);
99
+ extraParams.delete("search");
100
+ const extraActive = Array.from(extraParams.values()).filter((v) => v !== "").length;
101
+ const anyActive = Array.from(params.values()).filter((v) => v !== "").length > 0;
102
+
103
+ if (badge && extraActive > 0) {
104
+ badge.textContent = String(extraActive);
105
+ badge.classList.remove("hidden");
106
+ badge.classList.add("flex");
107
+ }
108
+
109
+ if (panel && extraActive > 0) {
110
+ panel.classList.remove("hidden");
111
+ panel.classList.add("flex");
112
+ }
113
+
114
+ if (clearBtn && anyActive) {
115
+ clearBtn.classList.remove("hidden");
116
+ clearBtn.classList.add("flex");
117
+ (clearBtn as HTMLAnchorElement).href = window.location.pathname;
118
+ }
119
+
120
+ if (toggle && panel) {
121
+ toggle.addEventListener("click", () => {
122
+ const isOpen = panel.classList.contains("flex");
123
+ if (isOpen) {
124
+ panel.classList.remove("flex");
125
+ panel.classList.add("hidden");
126
+ } else {
127
+ panel.classList.remove("hidden");
128
+ panel.classList.add("flex");
129
+ }
130
+ });
131
+ }
132
+ }
133
+
134
+ // `astro:page-load` fires on the initial load too; calling setupFilters()
135
+ // directly as well would double-bind the toggle button.
136
+ document.addEventListener("astro:page-load", setupFilters);
137
+ </script>
package/src/env.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /// <reference path="../.astro/types.d.ts" />
2
+ /// <reference path="../.astro/fse-ssr-translations.d.ts" />
@@ -0,0 +1,45 @@
1
+ ---
2
+ import { ssr } from "fse-ssr/ssr";
3
+ import type { SsrValue } from "fse-ssr/ssr";
4
+
5
+ interface Props {
6
+ title: string | SsrValue<string>;
7
+ }
8
+
9
+ const { title } = Astro.props;
10
+ const { base_url } = ssr<{ base_url: string }>();
11
+ ---
12
+
13
+ <!doctype html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="UTF-8" />
17
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
18
+ <title>{title}</title>
19
+ </head>
20
+ <body style="margin: 0; padding: 0; background-color: #f8f5f4; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
21
+ <div style="background-color: #f8f5f4; padding: 40px 20px; text-align: center;">
22
+ <div style="max-width: 560px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #ddd5d0; text-align: left;">
23
+ <!-- Header -->
24
+ <div style="background-color: #6a9b3c; padding: 32px 40px; text-align: center;">
25
+ <a href={base_url} target="_blank" style="display: inline-block; margin-bottom: 10px; color: rgba(255,255,255,0.75); font-size: 11px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; text-decoration: none;">Starter</a>
26
+ <slot name="header" />
27
+ </div>
28
+ <!-- Body -->
29
+ <div style="padding: 40px;">
30
+ <slot />
31
+ </div>
32
+ <!-- Wide slot: full-width sections between body and footer (optional) -->
33
+ <slot name="wide" />
34
+ <!-- Footer -->
35
+ <div style="padding: 24px 40px; background-color: #f4f9ee; border-top: 1px solid #e6f2d6; text-align: center;">
36
+ <slot name="footer">
37
+ <p style="margin: 0; color: #655650; font-size: 12px; line-height: 1.625;">
38
+ If you did not request this email, you can safely ignore it.
39
+ </p>
40
+ </slot>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </body>
45
+ </html>
@@ -0,0 +1,95 @@
1
+ ---
2
+ /**
3
+ * The app shell every theme page renders in: sidebar navigation (see
4
+ * Sidebar.astro), dark mode, view transitions. `sidebar={false}` gives a
5
+ * bare centered page (login, errors, …). A child theme overrides this file
6
+ * to change the shell for every inherited page at once.
7
+ */
8
+ import "../styles/global.css";
9
+ import { ClientRouter } from "astro:transitions";
10
+ import { ssr } from "fse-ssr/ssr";
11
+ import type { SsrValue } from "fse-ssr/ssr";
12
+ import Head from "../components/Head.astro";
13
+ import Logo from "../components/Logo.astro";
14
+ import Sidebar from "../components/Sidebar.astro";
15
+
16
+ interface Props {
17
+ title: string | SsrValue<string>;
18
+ sidebar?: boolean;
19
+ }
20
+
21
+ const { title, sidebar = true } = Astro.props;
22
+ const { lang } = ssr();
23
+ ---
24
+
25
+ <!doctype html>
26
+ <html lang={lang ?? "en"}>
27
+ <head>
28
+ <meta charset="UTF-8" />
29
+ <meta name="viewport" content="width=device-width" />
30
+ <meta name="generator" content={Astro.generator} />
31
+ <Head />
32
+ <title>{title}</title>
33
+ <!-- Filled by the server with the page's render context as JSON; read it
34
+ client-side via pageProps() from fse-ssr/client. -->
35
+ <script is:inline type="application/json" id="__fse-props__"></script>
36
+ <ClientRouter />
37
+ <script is:inline>
38
+ if (localStorage.getItem("sidebar-mini") === "1") {
39
+ document.documentElement.classList.add("sidebar-mini");
40
+ }
41
+ // Apply the saved (or system) color scheme before first paint.
42
+ (function () {
43
+ const saved = localStorage.getItem("theme");
44
+ const dark =
45
+ saved === "dark" ||
46
+ (!saved && window.matchMedia("(prefers-color-scheme: dark)").matches);
47
+ document.documentElement.classList.toggle("dark", dark);
48
+ })();
49
+ document.addEventListener("astro:before-swap", (e) => {
50
+ const root = e.newDocument.documentElement;
51
+ if (localStorage.getItem("sidebar-mini") === "1") root.classList.add("sidebar-mini");
52
+ if (document.documentElement.classList.contains("dark")) root.classList.add("dark");
53
+ });
54
+ </script>
55
+ </head>
56
+ <body class="bg-secondary-50 text-secondary-900">
57
+ <div class="relative flex min-h-screen">
58
+ {sidebar && <Sidebar />}
59
+ <div
60
+ class:list={[
61
+ "w-full min-w-0 flex flex-col",
62
+ sidebar && "py-6 px-5 lg:py-8 lg:px-8",
63
+ ]}
64
+ >
65
+ {sidebar && (
66
+ <div class="mb-4 flex items-center gap-4 lg:hidden">
67
+ <button
68
+ id="open-sidebar"
69
+ class="p-1.5 text-secondary-500 hover:text-secondary-900 hover:bg-secondary-100 transition-colors rounded-lg focus:outline-hidden"
70
+ aria-label="Open menu"
71
+ >
72
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
73
+ <line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line>
74
+ </svg>
75
+ </button>
76
+ <a href="/"><Logo class="h-8 w-auto" /></a>
77
+ </div>
78
+ )}
79
+ <slot />
80
+ </div>
81
+ </div>
82
+
83
+ <script>
84
+ document.addEventListener("astro:page-load", () => {
85
+ const openBtn = document.getElementById("open-sidebar");
86
+ if (openBtn && !openBtn.dataset.bound) {
87
+ openBtn.dataset.bound = "1";
88
+ openBtn.addEventListener("click", () =>
89
+ document.dispatchEvent(new CustomEvent("open-sidebar")),
90
+ );
91
+ }
92
+ });
93
+ </script>
94
+ </body>
95
+ </html>