@bygghammar/cms-platform 0.23.1 → 0.25.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/CHANGELOG.md +17 -0
- package/package.json +13 -3
- package/src/backoffice/AuditView.tsx +171 -0
- package/src/backoffice/ContactDetailsView.tsx +108 -0
- package/src/backoffice/DomainsView.tsx +264 -0
- package/src/backoffice/EmailLogsView.tsx +203 -0
- package/src/backoffice/LogsView.tsx +43 -0
- package/src/backoffice/NavigationView.tsx +133 -0
- package/src/backoffice/RolesView.tsx +50 -0
- package/src/backoffice/TrackingView.tsx +405 -0
- package/src/backoffice/UsersView.tsx +348 -0
- package/src/backoffice/VersionView.tsx +109 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,23 @@ always compatible. In addition, `PLATFORM_CONTRACT_VERSION` in
|
|
|
9
9
|
or adapter capabilities an older site does not have. Sites assert that constant
|
|
10
10
|
in a test and upgrade deliberately.
|
|
11
11
|
|
|
12
|
+
## 0.25.0
|
|
13
|
+
|
|
14
|
+
- Added shared backoffice views: `TrackingView` (code & tracking profiles),
|
|
15
|
+
`DomainsView` (languages & domains), `NavigationView` (navigation & footer),
|
|
16
|
+
`ContactDetailsView` and `VersionView`. Each takes host-supplied data and
|
|
17
|
+
actions, so site routes become thin adapters.
|
|
18
|
+
|
|
19
|
+
## 0.24.0
|
|
20
|
+
|
|
21
|
+
- Added `backoffice/UsersView`: shared account administration (create users,
|
|
22
|
+
toggle roles, set or reset passwords, delete), superadmin-gated.
|
|
23
|
+
- Added `backoffice/RolesView`: shared role reference list with default
|
|
24
|
+
descriptions that hosts can override.
|
|
25
|
+
- Added `backoffice/AuditView` and `backoffice/EmailLogsView`: shared log
|
|
26
|
+
browsers with search, status filtering and per-row detail.
|
|
27
|
+
- Added `backoffice/LogsView`: shared index over the two log surfaces.
|
|
28
|
+
|
|
12
29
|
## 0.23.0
|
|
13
30
|
|
|
14
31
|
- Added `config/site-config`: the typed `SiteConfig` contract, `defineSiteConfig`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -115,7 +115,17 @@
|
|
|
115
115
|
"./server/forms-core": "./src/server/forms-core.ts",
|
|
116
116
|
"./config/site-config": "./src/config/site-config.ts",
|
|
117
117
|
"./server/site-settings-core": "./src/server/site-settings-core.ts",
|
|
118
|
-
"./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx"
|
|
118
|
+
"./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx",
|
|
119
|
+
"./backoffice/UsersView": "./src/backoffice/UsersView.tsx",
|
|
120
|
+
"./backoffice/RolesView": "./src/backoffice/RolesView.tsx",
|
|
121
|
+
"./backoffice/AuditView": "./src/backoffice/AuditView.tsx",
|
|
122
|
+
"./backoffice/EmailLogsView": "./src/backoffice/EmailLogsView.tsx",
|
|
123
|
+
"./backoffice/LogsView": "./src/backoffice/LogsView.tsx",
|
|
124
|
+
"./backoffice/TrackingView": "./src/backoffice/TrackingView.tsx",
|
|
125
|
+
"./backoffice/DomainsView": "./src/backoffice/DomainsView.tsx",
|
|
126
|
+
"./backoffice/NavigationView": "./src/backoffice/NavigationView.tsx",
|
|
127
|
+
"./backoffice/ContactDetailsView": "./src/backoffice/ContactDetailsView.tsx",
|
|
128
|
+
"./backoffice/VersionView": "./src/backoffice/VersionView.tsx"
|
|
119
129
|
},
|
|
120
130
|
"dependencies": {
|
|
121
131
|
"fflate": "^0.8.3"
|
|
@@ -191,4 +201,4 @@
|
|
|
191
201
|
"bin": {
|
|
192
202
|
"cms-platform-migrations": "scripts/sync-migrations.mjs"
|
|
193
203
|
}
|
|
194
|
-
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Fragment, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatAbsoluteTime,
|
|
5
|
+
formatEntityRef,
|
|
6
|
+
formatRelativeTime,
|
|
7
|
+
humanizeAction,
|
|
8
|
+
summarizeMeta,
|
|
9
|
+
} from "../admin/format-log";
|
|
10
|
+
|
|
11
|
+
/** One audit trail row. */
|
|
12
|
+
export type AuditLogRow = {
|
|
13
|
+
id: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
actor_id?: string | null;
|
|
16
|
+
actor_email?: string | null;
|
|
17
|
+
action: string;
|
|
18
|
+
entity?: string | null;
|
|
19
|
+
entity_id?: string | null;
|
|
20
|
+
ip?: string | null;
|
|
21
|
+
user_agent?: string | null;
|
|
22
|
+
meta?: Record<string, unknown> | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Host-agnostic audit log browser with search and per-row detail. */
|
|
26
|
+
export function AuditView({ rows }: { rows: AuditLogRow[] }) {
|
|
27
|
+
const [search, setSearch] = useState("");
|
|
28
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
const filtered = useMemo(() => {
|
|
31
|
+
const s = search.trim().toLowerCase();
|
|
32
|
+
if (!s) return rows;
|
|
33
|
+
return rows.filter((r) => {
|
|
34
|
+
const hay = [
|
|
35
|
+
r.actor_email,
|
|
36
|
+
r.action,
|
|
37
|
+
humanizeAction(r.action),
|
|
38
|
+
r.entity,
|
|
39
|
+
r.entity_id,
|
|
40
|
+
JSON.stringify(r.meta ?? {}),
|
|
41
|
+
]
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join(" ")
|
|
44
|
+
.toLowerCase();
|
|
45
|
+
return hay.includes(s);
|
|
46
|
+
});
|
|
47
|
+
}, [rows, search]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="p-8 md:p-12">
|
|
51
|
+
<div className="flex items-start justify-between gap-6 flex-wrap">
|
|
52
|
+
<div>
|
|
53
|
+
<h2 className="font-serif text-xl">Audit log</h2>
|
|
54
|
+
<p className="mt-2 text-sm text-ink/60 max-w-2xl">
|
|
55
|
+
A human-readable trail of every sensitive action performed in the backoffice: who did
|
|
56
|
+
what, when, and to which record.
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
<input
|
|
60
|
+
value={search}
|
|
61
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
62
|
+
placeholder="Search actor, action, entity…"
|
|
63
|
+
className="border border-ink/15 bg-white px-3 py-2 text-sm w-72 focus:outline-none focus:border-sea"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div className="mt-6 border border-ink/10 bg-white overflow-hidden">
|
|
68
|
+
<table className="w-full text-sm">
|
|
69
|
+
<thead className="bg-ink/5 text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">
|
|
70
|
+
<tr>
|
|
71
|
+
<th className="text-left p-3 w-40">When</th>
|
|
72
|
+
<th className="text-left p-3 w-56">Who</th>
|
|
73
|
+
<th className="text-left p-3">What happened</th>
|
|
74
|
+
<th className="text-left p-3 w-56">Target</th>
|
|
75
|
+
<th className="w-10 p-3" />
|
|
76
|
+
</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
{filtered.map((r) => {
|
|
80
|
+
const summary = summarizeMeta(r.meta as never);
|
|
81
|
+
const isOpen = expanded === r.id;
|
|
82
|
+
return (
|
|
83
|
+
<Fragment key={r.id}>
|
|
84
|
+
<tr
|
|
85
|
+
className="border-t border-ink/10 align-top hover:bg-ink/[0.02] cursor-pointer"
|
|
86
|
+
onClick={() => setExpanded(isOpen ? null : r.id)}
|
|
87
|
+
>
|
|
88
|
+
<td className="p-3 whitespace-nowrap">
|
|
89
|
+
<div className="text-ink">{formatRelativeTime(r.created_at)}</div>
|
|
90
|
+
<div className="text-[0.65rem] text-ink/50">
|
|
91
|
+
{formatAbsoluteTime(r.created_at)}
|
|
92
|
+
</div>
|
|
93
|
+
</td>
|
|
94
|
+
<td className="p-3">
|
|
95
|
+
<div className="text-ink">{r.actor_email ?? "System"}</div>
|
|
96
|
+
{r.actor_id && (
|
|
97
|
+
<div className="text-[0.65rem] text-ink/40 font-mono">
|
|
98
|
+
{r.actor_id.slice(0, 8)}…
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</td>
|
|
102
|
+
<td className="p-3">
|
|
103
|
+
<div className="text-ink">{humanizeAction(r.action)}</div>
|
|
104
|
+
{summary && (
|
|
105
|
+
<div className="text-[0.7rem] text-ink/60 mt-1 line-clamp-2">{summary}</div>
|
|
106
|
+
)}
|
|
107
|
+
</td>
|
|
108
|
+
<td className="p-3 text-ink/70">{formatEntityRef(r.entity, r.entity_id)}</td>
|
|
109
|
+
<td className="p-3 text-ink/40 text-xs">{isOpen ? "▾" : "▸"}</td>
|
|
110
|
+
</tr>
|
|
111
|
+
{isOpen && (
|
|
112
|
+
<tr className="bg-ink/[0.02] border-t border-ink/10">
|
|
113
|
+
<td colSpan={5} className="p-4">
|
|
114
|
+
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
|
115
|
+
<DetailRow label="Action code" value={r.action} mono />
|
|
116
|
+
<DetailRow label="Entity" value={r.entity ?? "—"} />
|
|
117
|
+
<DetailRow label="Entity ID" value={r.entity_id ?? "—"} mono />
|
|
118
|
+
<DetailRow label="Actor ID" value={r.actor_id ?? "—"} mono />
|
|
119
|
+
<DetailRow label="IP" value={r.ip ?? "—"} mono />
|
|
120
|
+
<DetailRow label="User agent" value={r.user_agent ?? "—"} />
|
|
121
|
+
</dl>
|
|
122
|
+
{r.meta && Object.keys(r.meta).length > 0 && (
|
|
123
|
+
<div className="mt-4">
|
|
124
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50 mb-1">
|
|
125
|
+
Details
|
|
126
|
+
</div>
|
|
127
|
+
<pre className="text-[0.7rem] whitespace-pre-wrap break-all text-ink/70 bg-white border border-ink/10 p-3">
|
|
128
|
+
{JSON.stringify(r.meta, null, 2)}
|
|
129
|
+
</pre>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</td>
|
|
133
|
+
</tr>
|
|
134
|
+
)}
|
|
135
|
+
</Fragment>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
{filtered.length === 0 && (
|
|
139
|
+
<tr>
|
|
140
|
+
<td colSpan={5} className="p-6 text-center text-ink/50">
|
|
141
|
+
{rows.length === 0 ? "No audit events yet." : "No events match your search."}
|
|
142
|
+
</td>
|
|
143
|
+
</tr>
|
|
144
|
+
)}
|
|
145
|
+
</tbody>
|
|
146
|
+
</table>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function DetailRow({
|
|
153
|
+
label,
|
|
154
|
+
value,
|
|
155
|
+
mono,
|
|
156
|
+
}: {
|
|
157
|
+
label: string;
|
|
158
|
+
value: string;
|
|
159
|
+
mono?: boolean;
|
|
160
|
+
}) {
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex gap-3">
|
|
163
|
+
<dt className="text-ink/50 uppercase tracking-[0.18em] text-[0.6rem] w-28 shrink-0 pt-0.5">
|
|
164
|
+
{label}
|
|
165
|
+
</dt>
|
|
166
|
+
<dd className={mono ? "font-mono text-ink/80 break-all" : "text-ink/80 break-all"}>
|
|
167
|
+
{value}
|
|
168
|
+
</dd>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
|
|
4
|
+
/** One editable company field. */
|
|
5
|
+
export type ContactField = { key: string; label: string; type?: string };
|
|
6
|
+
|
|
7
|
+
/** The canonical company detail fields every site shares. */
|
|
8
|
+
export const CONTACT_FIELDS: ContactField[] = [
|
|
9
|
+
{ key: "locationName", label: "Name" },
|
|
10
|
+
{ key: "street", label: "Address" },
|
|
11
|
+
{ key: "postCode", label: "Post code" },
|
|
12
|
+
{ key: "city", label: "City" },
|
|
13
|
+
{ key: "country", label: "Country" },
|
|
14
|
+
{ key: "legalName", label: "Company details (legal name)" },
|
|
15
|
+
{ key: "orgNumber", label: "Org. number" },
|
|
16
|
+
{ key: "email", label: "Email", type: "email" },
|
|
17
|
+
{ key: "phone", label: "Phone", type: "tel" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Host-provided data and actions for the contact details screen. */
|
|
21
|
+
export type ContactDetailsDeps = {
|
|
22
|
+
/** Code defaults for the company block. */
|
|
23
|
+
defaults: Record<string, string>;
|
|
24
|
+
/** Saved override for the company block, if any. */
|
|
25
|
+
stored: Record<string, string> | undefined;
|
|
26
|
+
loading?: boolean;
|
|
27
|
+
/** Fields to render; defaults to `CONTACT_FIELDS`. */
|
|
28
|
+
fields?: ContactField[];
|
|
29
|
+
saveSection: (input: { data: { key: string; data: unknown } }) => Promise<unknown>;
|
|
30
|
+
refresh: () => Promise<unknown> | void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const input =
|
|
34
|
+
"mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm focus:border-sea focus:outline-none";
|
|
35
|
+
const lbl = "text-[0.65rem] uppercase tracking-[0.22em] text-ink/55";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Host-agnostic contact details screen. These company details are the single
|
|
39
|
+
* source of truth for the contact section, the footer and structured data.
|
|
40
|
+
*/
|
|
41
|
+
export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
|
|
42
|
+
const { defaults, stored, loading, fields = CONTACT_FIELDS, saveSection, refresh } = deps;
|
|
43
|
+
|
|
44
|
+
const [form, setForm] = useState<Record<string, string>>(defaults);
|
|
45
|
+
const [saving, setSaving] = useState(false);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (stored && typeof stored === "object") setForm({ ...defaults, ...stored });
|
|
49
|
+
}, [stored, defaults]);
|
|
50
|
+
|
|
51
|
+
const set = (key: string, value: string) => setForm((f) => ({ ...f, [key]: value }));
|
|
52
|
+
|
|
53
|
+
async function onSave() {
|
|
54
|
+
setSaving(true);
|
|
55
|
+
try {
|
|
56
|
+
await saveSection({ data: { key: "company", data: form } });
|
|
57
|
+
await refresh();
|
|
58
|
+
toast.success("Contact details saved");
|
|
59
|
+
} catch (e) {
|
|
60
|
+
toast.error(e instanceof Error ? e.message : "Could not save");
|
|
61
|
+
} finally {
|
|
62
|
+
setSaving(false);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="p-8 md:p-12 w-full">
|
|
68
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Settings</div>
|
|
69
|
+
<h2 className="mt-2 font-serif text-3xl text-ink">Contact details</h2>
|
|
70
|
+
<p className="mt-3 text-sm text-ink/65">
|
|
71
|
+
These company details are the single source of truth. They are used in the website contact
|
|
72
|
+
section, the footer and the structured data for search engines.
|
|
73
|
+
</p>
|
|
74
|
+
|
|
75
|
+
<div className="mt-10 grid gap-5 sm:grid-cols-2">
|
|
76
|
+
{fields.map((f) => (
|
|
77
|
+
<label key={f.key} className="block">
|
|
78
|
+
<span className={lbl}>{f.label}</span>
|
|
79
|
+
<input
|
|
80
|
+
className={input}
|
|
81
|
+
type={f.type ?? "text"}
|
|
82
|
+
value={form[f.key] ?? ""}
|
|
83
|
+
onChange={(e) => set(f.key, e.target.value)}
|
|
84
|
+
/>
|
|
85
|
+
</label>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div className="mt-10 flex items-center gap-4">
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={onSave}
|
|
93
|
+
disabled={saving || !!loading}
|
|
94
|
+
className="bg-ink px-6 py-3 text-xs uppercase tracking-[0.22em] text-paper disabled:opacity-50"
|
|
95
|
+
>
|
|
96
|
+
{saving ? "Saving..." : "Save details"}
|
|
97
|
+
</button>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => setForm(defaults)}
|
|
101
|
+
className="text-xs uppercase tracking-[0.22em] text-ink/55 hover:text-ink"
|
|
102
|
+
>
|
|
103
|
+
Reset to defaults
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Trash2, Plus, Check, X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/** One language-to-domain mapping. */
|
|
6
|
+
export type LocaleDomainRow = {
|
|
7
|
+
locale: string;
|
|
8
|
+
domain: string;
|
|
9
|
+
isDefault: boolean;
|
|
10
|
+
isAdminDomain: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Host-provided data, actions and navigation for the domains screen. */
|
|
14
|
+
export type DomainsDeps = {
|
|
15
|
+
domains: LocaleDomainRow[];
|
|
16
|
+
locales: readonly string[];
|
|
17
|
+
defaultLocale: string;
|
|
18
|
+
/** Path back to the backoffice home, e.g. `/kontoret`. */
|
|
19
|
+
backPath: string;
|
|
20
|
+
/** Example domain shown as the input placeholder. */
|
|
21
|
+
domainPlaceholder?: string;
|
|
22
|
+
saveDomain: (input: { data: LocaleDomainRow }) => Promise<unknown>;
|
|
23
|
+
deleteDomain: (input: { data: { locale: string } }) => Promise<unknown>;
|
|
24
|
+
refresh: () => Promise<unknown> | void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Host-agnostic languages & domains screen. */
|
|
28
|
+
export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
29
|
+
const {
|
|
30
|
+
domains,
|
|
31
|
+
locales,
|
|
32
|
+
defaultLocale: DEFAULT_LOCALE,
|
|
33
|
+
backPath,
|
|
34
|
+
domainPlaceholder = "www.example.com",
|
|
35
|
+
saveDomain: save,
|
|
36
|
+
deleteDomain: del,
|
|
37
|
+
refresh,
|
|
38
|
+
} = deps;
|
|
39
|
+
const [busy, setBusy] = useState(false);
|
|
40
|
+
const [editing, setEditing] = useState<LocaleDomainRow | null>(null);
|
|
41
|
+
const [draft, setDraft] = useState<Partial<LocaleDomainRow>>({
|
|
42
|
+
locale: DEFAULT_LOCALE,
|
|
43
|
+
domain: "",
|
|
44
|
+
isDefault: false,
|
|
45
|
+
isAdminDomain: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const reset = () => {
|
|
49
|
+
setEditing(null);
|
|
50
|
+
setDraft({
|
|
51
|
+
locale: DEFAULT_LOCALE,
|
|
52
|
+
domain: "",
|
|
53
|
+
isDefault: false,
|
|
54
|
+
isAdminDomain: false,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const submit = async (e: React.FormEvent) => {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
if (!draft.locale || !draft.domain) return;
|
|
61
|
+
setBusy(true);
|
|
62
|
+
try {
|
|
63
|
+
await save({
|
|
64
|
+
data: {
|
|
65
|
+
locale: draft.locale,
|
|
66
|
+
domain: draft.domain,
|
|
67
|
+
isDefault: !!draft.isDefault,
|
|
68
|
+
isAdminDomain: !!draft.isAdminDomain,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
await refresh();
|
|
72
|
+
toast.success("Domain saved");
|
|
73
|
+
reset();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
toast.error(err instanceof Error ? err.message : "Could not save");
|
|
76
|
+
} finally {
|
|
77
|
+
setBusy(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const remove = async (locale: string) => {
|
|
82
|
+
if (!confirm("Remove this domain mapping?")) return;
|
|
83
|
+
setBusy(true);
|
|
84
|
+
try {
|
|
85
|
+
await del({ data: { locale } });
|
|
86
|
+
await refresh();
|
|
87
|
+
toast.success("Domain removed");
|
|
88
|
+
} catch (err) {
|
|
89
|
+
toast.error(err instanceof Error ? err.message : "Could not remove");
|
|
90
|
+
} finally {
|
|
91
|
+
setBusy(false);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const startEdit = (d: LocaleDomainRow) => {
|
|
96
|
+
setEditing(d);
|
|
97
|
+
setDraft({ ...d });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<main className="p-6 md:p-10 w-full">
|
|
102
|
+
<div className="flex items-center justify-between">
|
|
103
|
+
<div>
|
|
104
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50">Backoffice</div>
|
|
105
|
+
<h1 className="mt-1 font-serif text-3xl">Languages & domains</h1>
|
|
106
|
+
</div>
|
|
107
|
+
<a
|
|
108
|
+
href={backPath}
|
|
109
|
+
className="text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink"
|
|
110
|
+
>
|
|
111
|
+
← Back
|
|
112
|
+
</a>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<p className="mt-4 max-w-2xl text-sm text-ink/70">
|
|
116
|
+
Map each language to its own domain. Mark one domain as the default (fallback) and one as
|
|
117
|
+
the admin domain where the backoffice is only available.
|
|
118
|
+
</p>
|
|
119
|
+
|
|
120
|
+
<section className="mt-10">
|
|
121
|
+
<h2 className="font-serif text-xl">Current domains</h2>
|
|
122
|
+
<div className="mt-4 overflow-x-auto border border-ink/10">
|
|
123
|
+
<table className="w-full text-sm">
|
|
124
|
+
<thead className="bg-ink text-paper text-left">
|
|
125
|
+
<tr>
|
|
126
|
+
<th className="px-4 py-3 font-medium">Language</th>
|
|
127
|
+
<th className="px-4 py-3 font-medium">Domain</th>
|
|
128
|
+
<th className="px-4 py-3 font-medium">Default</th>
|
|
129
|
+
<th className="px-4 py-3 font-medium">Admin</th>
|
|
130
|
+
<th className="px-4 py-3 font-medium"></th>
|
|
131
|
+
</tr>
|
|
132
|
+
</thead>
|
|
133
|
+
<tbody>
|
|
134
|
+
{domains.length === 0 && (
|
|
135
|
+
<tr>
|
|
136
|
+
<td colSpan={5} className="px-4 py-8 text-center text-ink/50">
|
|
137
|
+
No domains configured yet.
|
|
138
|
+
</td>
|
|
139
|
+
</tr>
|
|
140
|
+
)}
|
|
141
|
+
{domains.map((d) => (
|
|
142
|
+
<tr
|
|
143
|
+
key={d.locale}
|
|
144
|
+
className="border-b border-ink/5 last:border-b-0 hover:bg-ink/[0.02]"
|
|
145
|
+
>
|
|
146
|
+
<td className="px-4 py-3 font-medium uppercase">{d.locale}</td>
|
|
147
|
+
<td className="px-4 py-3 font-mono text-ink/80">{d.domain}</td>
|
|
148
|
+
<td className="px-4 py-3">
|
|
149
|
+
{d.isDefault ? (
|
|
150
|
+
<Check className="size-4 text-sea" />
|
|
151
|
+
) : (
|
|
152
|
+
<X className="size-4 text-ink/30" />
|
|
153
|
+
)}
|
|
154
|
+
</td>
|
|
155
|
+
<td className="px-4 py-3">
|
|
156
|
+
{d.isAdminDomain ? (
|
|
157
|
+
<Check className="size-4 text-sea" />
|
|
158
|
+
) : (
|
|
159
|
+
<X className="size-4 text-ink/30" />
|
|
160
|
+
)}
|
|
161
|
+
</td>
|
|
162
|
+
<td className="px-4 py-3">
|
|
163
|
+
<div className="flex items-center gap-2">
|
|
164
|
+
<button
|
|
165
|
+
onClick={() => startEdit(d)}
|
|
166
|
+
className="text-xs uppercase tracking-[0.15em] text-ink/70 hover:text-ink underline underline-offset-2"
|
|
167
|
+
>
|
|
168
|
+
Edit
|
|
169
|
+
</button>
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => remove(d.locale)}
|
|
172
|
+
disabled={busy}
|
|
173
|
+
className="p-1.5 text-ink/50 hover:text-red-600 transition-colors"
|
|
174
|
+
aria-label="Remove"
|
|
175
|
+
>
|
|
176
|
+
<Trash2 className="size-4" />
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
</td>
|
|
180
|
+
</tr>
|
|
181
|
+
))}
|
|
182
|
+
</tbody>
|
|
183
|
+
</table>
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
|
|
187
|
+
<section className="mt-12 max-w-xl">
|
|
188
|
+
<h2 className="font-serif text-xl">{editing ? "Edit domain" : "Add domain"}</h2>
|
|
189
|
+
<form onSubmit={submit} className="mt-4 space-y-4">
|
|
190
|
+
<label className="block">
|
|
191
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">Language</span>
|
|
192
|
+
<select
|
|
193
|
+
value={draft.locale}
|
|
194
|
+
onChange={(e) => setDraft((d) => ({ ...d, locale: e.target.value }))}
|
|
195
|
+
className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink focus:border-sea focus:outline-none"
|
|
196
|
+
>
|
|
197
|
+
{locales.map((l) => (
|
|
198
|
+
<option key={l} value={l}>
|
|
199
|
+
{l.toUpperCase()}
|
|
200
|
+
</option>
|
|
201
|
+
))}
|
|
202
|
+
</select>
|
|
203
|
+
</label>
|
|
204
|
+
|
|
205
|
+
<label className="block">
|
|
206
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">Domain</span>
|
|
207
|
+
<input
|
|
208
|
+
type="text"
|
|
209
|
+
required
|
|
210
|
+
value={draft.domain}
|
|
211
|
+
onChange={(e) => setDraft((d) => ({ ...d, domain: e.target.value }))}
|
|
212
|
+
placeholder={domainPlaceholder}
|
|
213
|
+
className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink placeholder:text-ink/40 focus:border-sea focus:outline-none"
|
|
214
|
+
/>
|
|
215
|
+
<span className="mt-1 block text-xs text-ink/50">
|
|
216
|
+
Enter the domain without https:// and without trailing slash.
|
|
217
|
+
</span>
|
|
218
|
+
</label>
|
|
219
|
+
|
|
220
|
+
<div className="flex flex-col gap-3 pt-2">
|
|
221
|
+
<label className="flex items-center gap-3 text-sm text-ink/80">
|
|
222
|
+
<input
|
|
223
|
+
type="checkbox"
|
|
224
|
+
checked={!!draft.isDefault}
|
|
225
|
+
onChange={(e) => setDraft((d) => ({ ...d, isDefault: e.target.checked }))}
|
|
226
|
+
className="size-4 border-ink/30 text-sea focus:ring-sea"
|
|
227
|
+
/>
|
|
228
|
+
Default domain (fallback when nothing else matches)
|
|
229
|
+
</label>
|
|
230
|
+
<label className="flex items-center gap-3 text-sm text-ink/80">
|
|
231
|
+
<input
|
|
232
|
+
type="checkbox"
|
|
233
|
+
checked={!!draft.isAdminDomain}
|
|
234
|
+
onChange={(e) => setDraft((d) => ({ ...d, isAdminDomain: e.target.checked }))}
|
|
235
|
+
className="size-4 border-ink/30 text-sea focus:ring-sea"
|
|
236
|
+
/>
|
|
237
|
+
Admin domain (/kontoret only accessible here)
|
|
238
|
+
</label>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="flex items-center gap-3 pt-4">
|
|
242
|
+
<button
|
|
243
|
+
type="submit"
|
|
244
|
+
disabled={busy}
|
|
245
|
+
className="inline-flex items-center gap-2 border border-ink bg-ink px-6 py-3 text-xs uppercase tracking-[0.22em] text-paper hover:bg-paper hover:text-ink transition-colors disabled:opacity-60"
|
|
246
|
+
>
|
|
247
|
+
<Plus className="size-4" />
|
|
248
|
+
{editing ? "Save changes" : "Add domain"}
|
|
249
|
+
</button>
|
|
250
|
+
{editing && (
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={reset}
|
|
254
|
+
className="text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink underline underline-offset-4"
|
|
255
|
+
>
|
|
256
|
+
Cancel
|
|
257
|
+
</button>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
</form>
|
|
261
|
+
</section>
|
|
262
|
+
</main>
|
|
263
|
+
);
|
|
264
|
+
}
|