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.
- package/README.md +55 -0
- package/astro.config.mjs +22 -0
- package/package.json +38 -0
- package/public/favicon.svg +4 -0
- package/src/assets/logo.svg +5 -0
- package/src/components/Card.astro +31 -0
- package/src/components/Head.astro +9 -0
- package/src/components/Header.astro +55 -0
- package/src/components/Input.astro +56 -0
- package/src/components/Logo.astro +17 -0
- package/src/components/Modal.astro +78 -0
- package/src/components/NavLink.astro +23 -0
- package/src/components/Pagination.astro +81 -0
- package/src/components/SearchBar.astro +65 -0
- package/src/components/Select.astro +60 -0
- package/src/components/Sidebar.astro +207 -0
- package/src/components/SidebarLinks.astro +10 -0
- package/src/components/Table.astro +157 -0
- package/src/components/TableFilters.astro +137 -0
- package/src/env.d.ts +2 -0
- package/src/layouts/EmailLayout.astro +45 -0
- package/src/layouts/Layout.astro +95 -0
- package/src/pages/emails/password-reset.astro +39 -0
- package/src/pages/emails/verify-email-change.astro +39 -0
- package/src/pages/emails/verify.astro +39 -0
- package/src/pages/error.astro +26 -0
- package/src/pages/forgot-password.astro +67 -0
- package/src/pages/fse/form.astro +169 -0
- package/src/pages/fse/links.astro +104 -0
- package/src/pages/fse/list.astro +166 -0
- package/src/pages/fse/public-detail.astro +29 -0
- package/src/pages/fse/public-list.astro +54 -0
- package/src/pages/index.astro +41 -0
- package/src/pages/login.astro +88 -0
- package/src/pages/public/error.astro +29 -0
- package/src/pages/register-success.astro +31 -0
- package/src/pages/register.astro +99 -0
- package/src/pages/reset-password.astro +117 -0
- package/src/pages/settings.astro +188 -0
- package/src/pages/user.astro +65 -0
- package/src/pages/users.astro +52 -0
- package/src/styles/global.css +11 -0
- package/src/styles/theme.css +408 -0
- package/src/types/pages.ts +58 -0
- package/src/types.ts +154 -0
- package/theme.json +5 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ssr } from "fse-ssr/ssr";
|
|
3
|
+
import EmailLayout from "../../layouts/EmailLayout.astro";
|
|
4
|
+
|
|
5
|
+
const { t, reset_url } = ssr<{ reset_url: string }>();
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<EmailLayout title={t.password_reset_email.subject}>
|
|
9
|
+
<h1 slot="header" style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600; letter-spacing: 0.025em;">
|
|
10
|
+
{t.password_reset_email.header}
|
|
11
|
+
</h1>
|
|
12
|
+
|
|
13
|
+
<p style="margin: 0; margin-bottom: 20px; color: #231f16; font-size: 16px; line-height: 1.625;">
|
|
14
|
+
{t.password_reset_email.body}
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<div style="text-align: center; padding-top: 12px; padding-bottom: 28px;">
|
|
18
|
+
<a
|
|
19
|
+
href={reset_url}
|
|
20
|
+
target="_blank"
|
|
21
|
+
style="display: inline-block; padding: 14px 36px; background-color: #6a9b3c; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; border-radius: 6px; letter-spacing: 0.025em;"
|
|
22
|
+
>
|
|
23
|
+
{t.password_reset_email.cta}
|
|
24
|
+
</a>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<p style="margin: 0; margin-bottom: 12px; color: #655650; font-size: 14px; line-height: 1.5;">
|
|
28
|
+
{t.password_reset_email.fallback}
|
|
29
|
+
</p>
|
|
30
|
+
<p style="margin: 0; word-break: break-all;">
|
|
31
|
+
<a href={reset_url} style="color: #6a9b3c; font-size: 12px; text-decoration: underline;">
|
|
32
|
+
{reset_url}
|
|
33
|
+
</a>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<p slot="footer" style="margin: 0; color: #655650; font-size: 12px; line-height: 1.625;">
|
|
37
|
+
{t.password_reset_email.footer}
|
|
38
|
+
</p>
|
|
39
|
+
</EmailLayout>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ssr } from "fse-ssr/ssr";
|
|
3
|
+
import EmailLayout from "../../layouts/EmailLayout.astro";
|
|
4
|
+
|
|
5
|
+
const { t, verify_url } = ssr<{ verify_url: string }>();
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<EmailLayout title={t.verify_email_change.subject}>
|
|
9
|
+
<h1 slot="header" style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600; letter-spacing: 0.025em;">
|
|
10
|
+
{t.verify_email_change.header}
|
|
11
|
+
</h1>
|
|
12
|
+
|
|
13
|
+
<p style="margin: 0; margin-bottom: 20px; color: #231f16; font-size: 16px; line-height: 1.625;">
|
|
14
|
+
{t.verify_email_change.body}
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<div style="text-align: center; padding-top: 12px; padding-bottom: 28px;">
|
|
18
|
+
<a
|
|
19
|
+
href={verify_url}
|
|
20
|
+
target="_blank"
|
|
21
|
+
style="display: inline-block; padding: 14px 36px; background-color: #6a9b3c; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; border-radius: 6px; letter-spacing: 0.025em;"
|
|
22
|
+
>
|
|
23
|
+
{t.verify_email_change.cta}
|
|
24
|
+
</a>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<p style="margin: 0; margin-bottom: 12px; color: #655650; font-size: 14px; line-height: 1.5;">
|
|
28
|
+
{t.verify_email_change.fallback}
|
|
29
|
+
</p>
|
|
30
|
+
<p style="margin: 0; word-break: break-all;">
|
|
31
|
+
<a href={verify_url} style="color: #6a9b3c; font-size: 12px; text-decoration: underline;">
|
|
32
|
+
{verify_url}
|
|
33
|
+
</a>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<p slot="footer" style="margin: 0; color: #655650; font-size: 12px; line-height: 1.625;">
|
|
37
|
+
{t.verify_email_change.footer}
|
|
38
|
+
</p>
|
|
39
|
+
</EmailLayout>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ssr } from "fse-ssr/ssr";
|
|
3
|
+
import EmailLayout from "../../layouts/EmailLayout.astro";
|
|
4
|
+
|
|
5
|
+
const { t, verify_url } = ssr<{ verify_url: string }>();
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<EmailLayout title={t.verify_email.subject}>
|
|
9
|
+
<h1 slot="header" style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600; letter-spacing: 0.025em;">
|
|
10
|
+
{t.verify_email.header}
|
|
11
|
+
</h1>
|
|
12
|
+
|
|
13
|
+
<p style="margin: 0; margin-bottom: 20px; color: #231f16; font-size: 16px; line-height: 1.625;">
|
|
14
|
+
{t.verify_email.body}
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<div style="text-align: center; padding-top: 12px; padding-bottom: 28px;">
|
|
18
|
+
<a
|
|
19
|
+
href={verify_url}
|
|
20
|
+
target="_blank"
|
|
21
|
+
style="display: inline-block; padding: 14px 36px; background-color: #6a9b3c; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; border-radius: 6px; letter-spacing: 0.025em;"
|
|
22
|
+
>
|
|
23
|
+
{t.verify_email.cta}
|
|
24
|
+
</a>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<p style="margin: 0; margin-bottom: 12px; color: #655650; font-size: 14px; line-height: 1.5;">
|
|
28
|
+
{t.verify_email.fallback}
|
|
29
|
+
</p>
|
|
30
|
+
<p style="margin: 0; word-break: break-all;">
|
|
31
|
+
<a href={verify_url} style="color: #6a9b3c; font-size: 12px; text-decoration: underline;">
|
|
32
|
+
{verify_url}
|
|
33
|
+
</a>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<p slot="footer" style="margin: 0; color: #655650; font-size: 12px; line-height: 1.625;">
|
|
37
|
+
{t.verify_email.footer}
|
|
38
|
+
</p>
|
|
39
|
+
</EmailLayout>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Error page for signed-in users (the framework renders `error`). */
|
|
3
|
+
import { ssr } from "fse-ssr/ssr";
|
|
4
|
+
import Layout from "../layouts/Layout.astro";
|
|
5
|
+
import type { ErrorPage } from "../types/pages";
|
|
6
|
+
|
|
7
|
+
const { t, error, status, lang_prefix } = ssr<ErrorPage & { status: number; lang_prefix: string }>();
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<Layout title={t.common.error ?? "Error"}>
|
|
11
|
+
<main class="grid place-items-center min-h-[60vh]">
|
|
12
|
+
<div class="w-full max-w-md text-center flex flex-col items-center gap-5">
|
|
13
|
+
<div class="w-12 h-12 rounded-full bg-red-50 border border-red-200 flex items-center justify-center">
|
|
14
|
+
<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" class="text-red-500">
|
|
15
|
+
<circle cx="12" cy="12" r="10"></circle><path d="M12 8v4m0 4h.01"></path>
|
|
16
|
+
</svg>
|
|
17
|
+
</div>
|
|
18
|
+
<div>
|
|
19
|
+
<p class="text-xs font-semibold text-secondary-400 mb-1">{status}</p>
|
|
20
|
+
<h1 class="text-base font-semibold text-secondary-900 mb-1">{t.common.error ?? "Error"}</h1>
|
|
21
|
+
<p class="text-sm text-secondary-500">{error}</p>
|
|
22
|
+
</div>
|
|
23
|
+
<a href={`${lang_prefix}/`} class="btn-secondary">{t.common.back_home ?? "Back to home"}</a>
|
|
24
|
+
</div>
|
|
25
|
+
</main>
|
|
26
|
+
</Layout>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ssr } from "fse-ssr/ssr";
|
|
3
|
+
import Input from "../components/Input.astro";
|
|
4
|
+
import Logo from "../components/Logo.astro";
|
|
5
|
+
import Layout from "../layouts/Layout.astro";
|
|
6
|
+
|
|
7
|
+
const { t, error, success } = ssr<{ error?: string; success?: string }>();
|
|
8
|
+
const errors = t.errors as Record<string, string>;
|
|
9
|
+
const messages = t.messages as Record<string, string>;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<Layout title={t.forgot_password.title} sidebar={false}>
|
|
13
|
+
<main class="min-h-screen w-full grid place-items-center bg-secondary-50 px-4 py-12">
|
|
14
|
+
<div class="w-full max-w-sm">
|
|
15
|
+
<div class="flex justify-center mb-8">
|
|
16
|
+
<a href="/">
|
|
17
|
+
<Logo class="h-12 w-auto" />
|
|
18
|
+
</a>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="bg-white border border-secondary-200 rounded-xl overflow-hidden">
|
|
22
|
+
<div class="px-6 pt-6 pb-2">
|
|
23
|
+
<h1 class="text-lg font-semibold text-secondary-900 mb-1">
|
|
24
|
+
{t.forgot_password.title}
|
|
25
|
+
</h1>
|
|
26
|
+
<p class="text-sm text-secondary-500">
|
|
27
|
+
{t.forgot_password.description}
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<form action="/forgot-password" method="POST" data-astro-reload>
|
|
32
|
+
<div class="px-6 py-4">
|
|
33
|
+
<Input id="email" type="email" placeholder={t.forgot_password.email} required={true} />
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="px-6 pb-5">
|
|
37
|
+
<button class="btn-primary w-full h-10 disabled:cursor-not-allowed disabled:opacity-50">
|
|
38
|
+
<span>{t.forgot_password.submit}</span>
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{error && (
|
|
43
|
+
<div class="px-6 pb-4">
|
|
44
|
+
<div class="w-full px-4 py-3 text-sm text-red-600 border border-red-200 rounded-lg bg-red-50" role="alert">
|
|
45
|
+
<p>{errors[error] ?? error}</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{success && (
|
|
51
|
+
<div class="px-6 pb-4">
|
|
52
|
+
<div class="w-full px-4 py-3 text-sm text-primary-700 border border-primary-200 rounded-lg bg-primary-50" role="alert">
|
|
53
|
+
<p>{messages[success] ?? success}</p>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
</form>
|
|
58
|
+
|
|
59
|
+
<div class="px-6 py-4 border-t border-secondary-100 text-center">
|
|
60
|
+
<a href="/login" class="text-sm text-primary-600 hover:text-primary-700 hover:underline font-medium">
|
|
61
|
+
{t.forgot_password.back_to_login}
|
|
62
|
+
</a>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</main>
|
|
67
|
+
</Layout>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Generic create/edit form — one page serves both (the server passes
|
|
4
|
+
* `is_new`). Widgets come from the column metadata; validation errors are
|
|
5
|
+
* re-rendered per field with codes translated through the app's locales.
|
|
6
|
+
* Without `can_edit` (no write permission, or the model's hooks locked the
|
|
7
|
+
* row) the same form renders disabled and without a save button. An
|
|
8
|
+
* existing row also shows its allowed row actions and links to its nested
|
|
9
|
+
* models and link tables; a nested model links back to its parent row.
|
|
10
|
+
*/
|
|
11
|
+
import { ssr } from "fse-ssr/ssr";
|
|
12
|
+
import type { SsrValue } from "fse-ssr/ssr";
|
|
13
|
+
import Layout from "../../layouts/Layout.astro";
|
|
14
|
+
import type { ModelFormPage } from "../../types";
|
|
15
|
+
|
|
16
|
+
const { t, lang_prefix, meta, row, errors, is_new, can_edit, can_delete, actions, parent } =
|
|
17
|
+
ssr<ModelFormPage>();
|
|
18
|
+
const title = t.models[meta.table].title ?? meta.table;
|
|
19
|
+
const formErrors = t.form_errors as Record<string, SsrValue<string>>;
|
|
20
|
+
const inputClass =
|
|
21
|
+
"w-full px-3 py-2 text-sm border border-secondary-200 rounded-lg bg-white focus:outline-hidden focus:ring-2 focus:ring-primary-500";
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<Layout title={title}>
|
|
25
|
+
<main class="max-w-2xl">
|
|
26
|
+
{parent && (
|
|
27
|
+
<a class="mb-2 inline-block text-sm text-secondary-500 hover:text-secondary-800" href={`${lang_prefix}${parent.meta.base_path}/${parent.row.id}`}>
|
|
28
|
+
← {parent.row[parent.meta.title_field]}
|
|
29
|
+
</a>
|
|
30
|
+
)}
|
|
31
|
+
<div class="mb-6 flex items-center justify-between gap-4">
|
|
32
|
+
<h1 class="section-title">{title}</h1>
|
|
33
|
+
<a class="btn-ghost" href={`${lang_prefix}${meta.base_path}`}>{t.model_ui.back ?? "Back"}</a>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
{!is_new && meta.has_subpages && (
|
|
37
|
+
<nav class="mb-6 flex flex-wrap gap-2">
|
|
38
|
+
{meta.children.map((child) => (
|
|
39
|
+
<a class="btn-secondary" href={`${lang_prefix}${meta.base_path}/${row.id}/${child.segment}`}>
|
|
40
|
+
{t.models[child.table].title ?? child.table}
|
|
41
|
+
</a>
|
|
42
|
+
))}
|
|
43
|
+
{meta.links.map((link) => (
|
|
44
|
+
<a class="btn-secondary" href={`${lang_prefix}${meta.base_path}/${row.id}/${link.segment}`}>
|
|
45
|
+
{t.models[link.table].title ?? link.table}
|
|
46
|
+
</a>
|
|
47
|
+
))}
|
|
48
|
+
</nav>
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
{!can_edit && !is_new && (
|
|
52
|
+
<p class="mb-4 text-sm text-secondary-500">{t.model_ui.read_only ?? "This entry is read-only."}</p>
|
|
53
|
+
)}
|
|
54
|
+
|
|
55
|
+
<form
|
|
56
|
+
method="post"
|
|
57
|
+
action={is_new ? `${lang_prefix}${meta.base_path}/create` : `${lang_prefix}${meta.base_path}/${row.id}`}
|
|
58
|
+
class="bg-white border border-secondary-200 rounded-xl p-6 flex flex-col gap-5"
|
|
59
|
+
>
|
|
60
|
+
<fieldset disabled={!can_edit} class="contents">
|
|
61
|
+
{meta.form_columns.map((col) => (
|
|
62
|
+
<div>
|
|
63
|
+
<label for={col.name} class="block text-sm font-medium mb-1.5">
|
|
64
|
+
{t.models[meta.table].fields[col.name] ?? col.name}
|
|
65
|
+
</label>
|
|
66
|
+
|
|
67
|
+
{col.widget === "textarea" && (
|
|
68
|
+
<textarea id={col.name} name={col.name} rows="5" required={col.required} class={inputClass}>{row[col.name] ?? ""}</textarea>
|
|
69
|
+
)}
|
|
70
|
+
{col.widget === "json" && (
|
|
71
|
+
<textarea id={col.name} name={col.name} rows="5" class={`${inputClass} font-mono`}>{row[col.name] ?? ""}</textarea>
|
|
72
|
+
)}
|
|
73
|
+
{col.widget === "select" && (
|
|
74
|
+
<select id={col.name} name={col.name} required={col.required} class={inputClass}>
|
|
75
|
+
{!col.required && <option value="">—</option>}
|
|
76
|
+
{col.options && col.options.map((option) => (
|
|
77
|
+
<option value={option} selected={row[col.name] === option}>
|
|
78
|
+
{t.models[meta.table].options[option] ?? option}
|
|
79
|
+
</option>
|
|
80
|
+
))}
|
|
81
|
+
</select>
|
|
82
|
+
)}
|
|
83
|
+
{col.widget === "relation" && (
|
|
84
|
+
col.options ? (
|
|
85
|
+
<select id={col.name} name={col.name} required={col.required} class={inputClass}>
|
|
86
|
+
<option value="">{t.model_ui.choose ?? "Choose..."}</option>
|
|
87
|
+
{col.options.map((option) => (
|
|
88
|
+
<option value={option.id} selected={row[col.name] === option.id}>{option.title}</option>
|
|
89
|
+
))}
|
|
90
|
+
</select>
|
|
91
|
+
) : (
|
|
92
|
+
<input id={col.name} type="number" name={col.name} value={row[col.name] ?? ""} required={col.required} class={inputClass} />
|
|
93
|
+
)
|
|
94
|
+
)}
|
|
95
|
+
{col.widget === "checkbox" && (
|
|
96
|
+
<input id={col.name} type="checkbox" name={col.name} value="true" checked={row[col.name]} class="h-4 w-4 rounded border-secondary-300 accent-primary-600" />
|
|
97
|
+
)}
|
|
98
|
+
{col.widget === "number" && (
|
|
99
|
+
<input id={col.name} type="number" step="any" name={col.name} value={row[col.name] ?? ""} required={col.required} class={inputClass} />
|
|
100
|
+
)}
|
|
101
|
+
{col.widget === "datetime" && (
|
|
102
|
+
<input id={col.name} type="datetime-local" name={col.name} value={row[col.name] ?? ""} required={col.required} class={inputClass} />
|
|
103
|
+
)}
|
|
104
|
+
{col.widget === "date" && (
|
|
105
|
+
<input id={col.name} type="date" name={col.name} value={row[col.name] ?? ""} required={col.required} class={inputClass} />
|
|
106
|
+
)}
|
|
107
|
+
{col.widget === "email" && (
|
|
108
|
+
<input id={col.name} type="email" name={col.name} value={row[col.name] ?? ""} required={col.required} class={inputClass} />
|
|
109
|
+
)}
|
|
110
|
+
{col.widget === "url" && (
|
|
111
|
+
<input id={col.name} type="url" name={col.name} value={row[col.name] ?? ""} required={col.required} class={inputClass} />
|
|
112
|
+
)}
|
|
113
|
+
{col.widget === "text" && (
|
|
114
|
+
<input id={col.name} type="text" name={col.name} value={row[col.name] ?? ""} required={col.required} class={inputClass} />
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{errors.map((error) => error.field === col.name && (
|
|
118
|
+
<p class="mt-1.5 text-sm text-red-600">
|
|
119
|
+
{formErrors[error.code] ?? error.code}
|
|
120
|
+
</p>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
))}
|
|
124
|
+
</fieldset>
|
|
125
|
+
|
|
126
|
+
<div class="flex items-center gap-3 pt-2">
|
|
127
|
+
{can_edit && (
|
|
128
|
+
<button type="submit" class="btn-primary">
|
|
129
|
+
{t.model_ui.save ?? "Save"}
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
<a class="btn-secondary" href={`${lang_prefix}${meta.base_path}`}>{t.model_ui.cancel ?? "Cancel"}</a>
|
|
133
|
+
{can_delete && (
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
class="btn-danger ml-auto"
|
|
137
|
+
data-fse-delete={`${lang_prefix}${meta.base_path}/${row.id}`}
|
|
138
|
+
data-back={`${lang_prefix}${meta.base_path}`}
|
|
139
|
+
data-confirm={t.model_ui.confirm_delete ?? "Delete this entry?"}
|
|
140
|
+
>
|
|
141
|
+
{t.model_ui.delete ?? "Delete"}
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</form>
|
|
146
|
+
|
|
147
|
+
{actions.length > 0 && (
|
|
148
|
+
<div class="mt-4 flex flex-wrap gap-2">
|
|
149
|
+
{actions.map((action) => (
|
|
150
|
+
<form method="post" action={`${lang_prefix}${meta.base_path}/${row.id}/actions/${action}`}>
|
|
151
|
+
<button type="submit" class="btn-secondary">
|
|
152
|
+
{t.models[meta.table].actions[action] ?? action}
|
|
153
|
+
</button>
|
|
154
|
+
</form>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</main>
|
|
159
|
+
|
|
160
|
+
<script>
|
|
161
|
+
for (const btn of document.querySelectorAll<HTMLButtonElement>("[data-fse-delete]")) {
|
|
162
|
+
btn.addEventListener("click", async () => {
|
|
163
|
+
if (!confirm(btn.getAttribute("data-confirm") ?? "Delete?")) return;
|
|
164
|
+
const res = await fetch(btn.getAttribute("data-fse-delete") ?? "", { method: "DELETE" });
|
|
165
|
+
if (res.ok) location.href = btn.getAttribute("data-back") ?? "/";
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
</script>
|
|
169
|
+
</Layout>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Generic many-to-many page (`#[model(link = ...)]`): the rows of another
|
|
4
|
+
* model linked to one row, with a search to add more and a button to remove
|
|
5
|
+
* each. Mounted at `{owner base}/{id}/{segment}`; override it per owner with
|
|
6
|
+
* a page at that namespace (e.g. `event-manager/managers`).
|
|
7
|
+
*/
|
|
8
|
+
import { ssr } from "fse-ssr/ssr";
|
|
9
|
+
import Layout from "../../layouts/Layout.astro";
|
|
10
|
+
import type { ModelLinksPage } from "../../types";
|
|
11
|
+
|
|
12
|
+
const { t, lang_prefix, parent, link, linked, can_write } = ssr<ModelLinksPage>();
|
|
13
|
+
const title = t.models[link.table].title ?? link.table;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<Layout title={title}>
|
|
17
|
+
<main class="max-w-2xl">
|
|
18
|
+
<a class="mb-2 inline-block text-sm text-secondary-500 hover:text-secondary-800" href={`${lang_prefix}${parent.meta.base_path}/${parent.row.id}`}>
|
|
19
|
+
← {parent.row[parent.meta.title_field]}
|
|
20
|
+
</a>
|
|
21
|
+
<h1 class="section-title mb-6">{title}</h1>
|
|
22
|
+
|
|
23
|
+
<div class="bg-white border border-secondary-200 rounded-xl">
|
|
24
|
+
{linked.length === 0 && (
|
|
25
|
+
<p class="p-6 text-sm text-secondary-500">{t.model_ui.no_links ?? "Nothing linked yet."}</p>
|
|
26
|
+
)}
|
|
27
|
+
{linked.map((item) => (
|
|
28
|
+
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-secondary-100 last:border-b-0">
|
|
29
|
+
<span>{item.title}</span>
|
|
30
|
+
{can_write && (
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
class="btn-ghost"
|
|
34
|
+
data-fse-unlink={`${lang_prefix}${link.base_path}/${item.id}`}
|
|
35
|
+
>
|
|
36
|
+
{t.model_ui.remove_link ?? "Remove"}
|
|
37
|
+
</button>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
{can_write && (
|
|
44
|
+
<div class="mt-6" data-fse-link-picker={`${lang_prefix}${link.base_path}`}>
|
|
45
|
+
<input
|
|
46
|
+
type="search"
|
|
47
|
+
data-fse-link-search
|
|
48
|
+
placeholder={t.model_ui.search_to_add ?? "Search to add..."}
|
|
49
|
+
class="w-full px-3 py-2 text-sm border border-secondary-200 rounded-lg bg-white focus:outline-hidden focus:ring-2 focus:ring-primary-500"
|
|
50
|
+
/>
|
|
51
|
+
<form method="post" action={`${lang_prefix}${link.base_path}`} data-fse-link-results class="mt-2 flex flex-col gap-1"></form>
|
|
52
|
+
<template data-fse-link-labels data-add={t.model_ui.add_link ?? "Add"} data-linked={t.model_ui.linked ?? "Linked"}></template>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</main>
|
|
56
|
+
|
|
57
|
+
<script>
|
|
58
|
+
for (const btn of document.querySelectorAll<HTMLButtonElement>("[data-fse-unlink]")) {
|
|
59
|
+
btn.addEventListener("click", async () => {
|
|
60
|
+
const res = await fetch(btn.getAttribute("data-fse-unlink") ?? "", { method: "DELETE" });
|
|
61
|
+
if (res.ok) location.reload();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const picker = document.querySelector<HTMLElement>("[data-fse-link-picker]");
|
|
66
|
+
const search = picker?.querySelector<HTMLInputElement>("[data-fse-link-search]");
|
|
67
|
+
const results = picker?.querySelector<HTMLFormElement>("[data-fse-link-results]");
|
|
68
|
+
const labels = picker?.querySelector<HTMLElement>("[data-fse-link-labels]");
|
|
69
|
+
let timer: number | undefined;
|
|
70
|
+
search?.addEventListener("input", () => {
|
|
71
|
+
window.clearTimeout(timer);
|
|
72
|
+
timer = window.setTimeout(async () => {
|
|
73
|
+
const base = picker?.getAttribute("data-fse-link-picker") ?? "";
|
|
74
|
+
const res = await fetch(`${base}/candidates?search=${encodeURIComponent(search.value)}`);
|
|
75
|
+
if (!res.ok || !results) return;
|
|
76
|
+
const { rows } = (await res.json()) as { rows: { id: number; title: string; linked: boolean }[] };
|
|
77
|
+
results.replaceChildren(
|
|
78
|
+
...rows.map((row) => {
|
|
79
|
+
const line = document.createElement("div");
|
|
80
|
+
line.className = "flex items-center justify-between gap-4 px-3 py-2 rounded-lg hover:bg-secondary-50";
|
|
81
|
+
const name = document.createElement("span");
|
|
82
|
+
name.textContent = String(row.title);
|
|
83
|
+
line.append(name);
|
|
84
|
+
if (row.linked) {
|
|
85
|
+
const done = document.createElement("span");
|
|
86
|
+
done.className = "text-sm text-secondary-400";
|
|
87
|
+
done.textContent = labels?.dataset.linked ?? "Linked";
|
|
88
|
+
line.append(done);
|
|
89
|
+
} else {
|
|
90
|
+
const add = document.createElement("button");
|
|
91
|
+
add.type = "submit";
|
|
92
|
+
add.name = "id";
|
|
93
|
+
add.value = String(row.id);
|
|
94
|
+
add.className = "btn-secondary";
|
|
95
|
+
add.textContent = labels?.dataset.add ?? "Add";
|
|
96
|
+
line.append(add);
|
|
97
|
+
}
|
|
98
|
+
return line;
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}, 200);
|
|
102
|
+
});
|
|
103
|
+
</script>
|
|
104
|
+
</Layout>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Generic admin list page — rendered for every #[model] struct that doesn't
|
|
4
|
+
* bring its own list template. Columns, search, filters and pagination all
|
|
5
|
+
* come from the model metadata the framework injects; labels resolve from
|
|
6
|
+
* the app's locales (models.{table}.fields.{column}) with the raw name as
|
|
7
|
+
* fallback.
|
|
8
|
+
*/
|
|
9
|
+
import { ssr } from "fse-ssr/ssr";
|
|
10
|
+
import Layout from "../../layouts/Layout.astro";
|
|
11
|
+
import type { ModelListPage } from "../../types";
|
|
12
|
+
|
|
13
|
+
const { t, lang_prefix, meta, rows, page, total_pages, total, has_prev, has_next, prev_page, next_page, search, filters, can_create, parent } =
|
|
14
|
+
ssr<ModelListPage>();
|
|
15
|
+
const title = t.models[meta.table].title ?? meta.table;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<Layout title={title}>
|
|
19
|
+
<main>
|
|
20
|
+
{parent && (
|
|
21
|
+
<a class="mb-2 inline-block text-sm text-secondary-500 hover:text-secondary-800" href={`${lang_prefix}${parent.meta.base_path}/${parent.row.id}`}>
|
|
22
|
+
← {parent.row[parent.meta.title_field]}
|
|
23
|
+
</a>
|
|
24
|
+
)}
|
|
25
|
+
<div class="mb-6 flex items-center justify-between gap-4">
|
|
26
|
+
<h1 class="section-title">{title}</h1>
|
|
27
|
+
{can_create && (
|
|
28
|
+
<a class="btn-primary" href={`${lang_prefix}${meta.base_path}/create`}>
|
|
29
|
+
{t.model_ui.new ?? "New"}
|
|
30
|
+
</a>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="bg-white border border-secondary-200 rounded-xl overflow-hidden">
|
|
35
|
+
<form method="get" class="flex flex-wrap items-center gap-3 p-4 border-b border-secondary-200">
|
|
36
|
+
<input
|
|
37
|
+
type="search"
|
|
38
|
+
name="search"
|
|
39
|
+
value={search ?? ""}
|
|
40
|
+
placeholder={t.model_ui.search ?? "Search..."}
|
|
41
|
+
class="flex-1 min-w-40 px-3 py-2 text-sm border border-secondary-200 rounded-lg bg-white focus:outline-hidden focus:ring-2 focus:ring-primary-500"
|
|
42
|
+
/>
|
|
43
|
+
{meta.filter_columns.map((col) => (
|
|
44
|
+
<>
|
|
45
|
+
{col.filter === "exact" && (
|
|
46
|
+
<select
|
|
47
|
+
name={col.name}
|
|
48
|
+
class="px-3 py-2 text-sm border border-secondary-200 rounded-lg bg-white"
|
|
49
|
+
>
|
|
50
|
+
<option value="">{t.models[meta.table].fields[col.name] ?? col.name}</option>
|
|
51
|
+
{col.options && col.options.map((option) => (
|
|
52
|
+
<option value={option} selected={filters[col.name] === option}>
|
|
53
|
+
{t.models[meta.table].options[option] ?? option}
|
|
54
|
+
</option>
|
|
55
|
+
))}
|
|
56
|
+
</select>
|
|
57
|
+
)}
|
|
58
|
+
{col.filter === "contains" && (
|
|
59
|
+
<input
|
|
60
|
+
type="text"
|
|
61
|
+
name={col.name}
|
|
62
|
+
value={filters[col.name]}
|
|
63
|
+
placeholder={t.models[meta.table].fields[col.name] ?? col.name}
|
|
64
|
+
class="min-w-32 px-3 py-2 text-sm border border-secondary-200 rounded-lg bg-white"
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
{col.filter === "range" && (
|
|
68
|
+
<span class="flex items-center gap-1.5 text-sm text-secondary-500">
|
|
69
|
+
{t.models[meta.table].fields[col.name] ?? col.name}
|
|
70
|
+
{col.filter_params.map((param) => (
|
|
71
|
+
<input
|
|
72
|
+
type={col.widget === "number" ? "number" : "date"}
|
|
73
|
+
name={param}
|
|
74
|
+
value={filters[param]}
|
|
75
|
+
class="px-2 py-2 text-sm border border-secondary-200 rounded-lg bg-white"
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</>
|
|
81
|
+
))}
|
|
82
|
+
<button class="btn-secondary" type="submit">{t.model_ui.filter ?? "Filter"}</button>
|
|
83
|
+
</form>
|
|
84
|
+
|
|
85
|
+
<table class="w-full text-sm">
|
|
86
|
+
<thead>
|
|
87
|
+
<tr class="text-left text-secondary-500 border-b border-secondary-200">
|
|
88
|
+
{meta.list_columns.map((col) => (
|
|
89
|
+
<th class="px-4 py-3 font-medium">
|
|
90
|
+
<a href={`?sort=${col.name}&search=${search ?? ""}`}>
|
|
91
|
+
{t.models[meta.table].fields[col.name] ?? col.name}
|
|
92
|
+
</a>
|
|
93
|
+
</th>
|
|
94
|
+
))}
|
|
95
|
+
<th class="px-4 py-3"></th>
|
|
96
|
+
</tr>
|
|
97
|
+
</thead>
|
|
98
|
+
<tbody>
|
|
99
|
+
{rows.map((row) => (
|
|
100
|
+
<tr class="border-b border-secondary-100 last:border-b-0 hover:bg-secondary-50">
|
|
101
|
+
{meta.list_columns.map((col) => (
|
|
102
|
+
<td class="px-4 py-3">
|
|
103
|
+
{col.relation ? (
|
|
104
|
+
row[col.relation] && row[col.relation].title
|
|
105
|
+
) : (
|
|
106
|
+
row[col.display]
|
|
107
|
+
)}
|
|
108
|
+
</td>
|
|
109
|
+
))}
|
|
110
|
+
<td class="px-4 py-3 text-right whitespace-nowrap">
|
|
111
|
+
{row._actions && row._actions.map((action: string) => (
|
|
112
|
+
<form method="post" class="inline" action={`${lang_prefix}${meta.base_path}/${row.id}/actions/${action}`}>
|
|
113
|
+
<button type="submit" class="btn-ghost">{t.models[meta.table].actions[action] ?? action}</button>
|
|
114
|
+
</form>
|
|
115
|
+
))}
|
|
116
|
+
<a class="btn-ghost" href={`${lang_prefix}${meta.base_path}/${row.id}`}>
|
|
117
|
+
{t.model_ui.edit ?? "Edit"}
|
|
118
|
+
</a>
|
|
119
|
+
{meta.can_write && !meta.no_delete && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
class="btn-danger"
|
|
123
|
+
data-fse-delete={`${lang_prefix}${meta.base_path}/${row.id}`}
|
|
124
|
+
data-confirm={t.model_ui.confirm_delete ?? "Delete this entry?"}
|
|
125
|
+
>
|
|
126
|
+
{t.model_ui.delete ?? "Delete"}
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
129
|
+
</td>
|
|
130
|
+
</tr>
|
|
131
|
+
))}
|
|
132
|
+
</tbody>
|
|
133
|
+
</table>
|
|
134
|
+
{rows.length === 0 && (
|
|
135
|
+
<p class="p-6 text-sm text-secondary-500">{t.model_ui.no_entries ?? "No entries yet."}</p>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
<div class="flex items-center justify-between gap-4 p-4 border-t border-secondary-200 text-sm text-secondary-500">
|
|
139
|
+
<span>{total} {t.model_ui.entries ?? "entries"}</span>
|
|
140
|
+
<div class="flex items-center gap-2">
|
|
141
|
+
{has_prev && (
|
|
142
|
+
<a class="btn-ghost" href={`?page=${prev_page}&search=${search ?? ""}`}>
|
|
143
|
+
{t.model_ui.prev ?? "Previous"}
|
|
144
|
+
</a>
|
|
145
|
+
)}
|
|
146
|
+
<span>{page} / {total_pages}</span>
|
|
147
|
+
{has_next && (
|
|
148
|
+
<a class="btn-ghost" href={`?page=${next_page}&search=${search ?? ""}`}>
|
|
149
|
+
{t.model_ui.next ?? "Next"}
|
|
150
|
+
</a>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</main>
|
|
156
|
+
|
|
157
|
+
<script>
|
|
158
|
+
for (const btn of document.querySelectorAll<HTMLButtonElement>("[data-fse-delete]")) {
|
|
159
|
+
btn.addEventListener("click", async () => {
|
|
160
|
+
if (!confirm(btn.getAttribute("data-confirm") ?? "Delete?")) return;
|
|
161
|
+
const res = await fetch(btn.getAttribute("data-fse-delete") ?? "", { method: "DELETE" });
|
|
162
|
+
if (res.ok) location.reload();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
</script>
|
|
166
|
+
</Layout>
|