@bygghammar/cms-platform 0.16.0 → 0.18.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/package.json +9 -3
- package/src/backoffice/FormEditorView.tsx +839 -0
- package/src/backoffice/FormsIndexView.tsx +435 -0
- package/src/backoffice/MediaLibraryView.tsx +1090 -0
- package/src/backoffice/RedirectsView.tsx +223 -0
- package/src/media/library-types.ts +22 -0
- package/src/media/pick-video-poster.tsx +128 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import { Plus, Trash2, Save } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/** One stored redirect row. */
|
|
6
|
+
export type RedirectRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
from_path: string;
|
|
9
|
+
to_path: string;
|
|
10
|
+
status: number;
|
|
11
|
+
created_at?: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Host-provided redirect data access. */
|
|
15
|
+
export type RedirectsDeps = {
|
|
16
|
+
rows: RedirectRow[];
|
|
17
|
+
saveRedirect: (input: {
|
|
18
|
+
data: { id?: string; from_path: string; to_path: string; status: number };
|
|
19
|
+
}) => Promise<unknown>;
|
|
20
|
+
deleteRedirect: (input: { data: { id: string } }) => Promise<unknown>;
|
|
21
|
+
refresh: () => Promise<unknown> | void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Host-agnostic redirect manager: add, edit inline and delete redirects. */
|
|
25
|
+
export function RedirectsView({ deps }: { deps: RedirectsDeps }) {
|
|
26
|
+
const { rows, saveRedirect, deleteRedirect, refresh } = deps;
|
|
27
|
+
const [from, setFrom] = useState("");
|
|
28
|
+
const [to, setTo] = useState("");
|
|
29
|
+
const [status, setStatus] = useState(301);
|
|
30
|
+
const [busy, setBusy] = useState(false);
|
|
31
|
+
|
|
32
|
+
const onAdd = async () => {
|
|
33
|
+
if (!from || !to) return;
|
|
34
|
+
setBusy(true);
|
|
35
|
+
try {
|
|
36
|
+
await saveRedirect({ data: { from_path: from, to_path: to, status } });
|
|
37
|
+
toast.success("Redirect saved");
|
|
38
|
+
setFrom("");
|
|
39
|
+
setTo("");
|
|
40
|
+
await refresh();
|
|
41
|
+
} catch (e) {
|
|
42
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
43
|
+
} finally {
|
|
44
|
+
setBusy(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const onUpdate = async (row: {
|
|
49
|
+
id: string;
|
|
50
|
+
from_path: string;
|
|
51
|
+
to_path: string;
|
|
52
|
+
status: number;
|
|
53
|
+
}) => {
|
|
54
|
+
try {
|
|
55
|
+
await saveRedirect({ data: row });
|
|
56
|
+
toast.success("Updated");
|
|
57
|
+
await refresh();
|
|
58
|
+
} catch (e) {
|
|
59
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const onDelete = async (id: string) => {
|
|
64
|
+
if (!window.confirm("Delete this redirect?")) return;
|
|
65
|
+
try {
|
|
66
|
+
await deleteRedirect({ data: { id } });
|
|
67
|
+
toast.success("Deleted");
|
|
68
|
+
await refresh();
|
|
69
|
+
} catch (e) {
|
|
70
|
+
toast.error(e instanceof Error ? e.message : "Failed");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="p-8 md:p-12 w-full">
|
|
76
|
+
<h1 className="font-serif text-3xl md:text-4xl text-ink">Redirects</h1>
|
|
77
|
+
<p className="mt-3 max-w-2xl text-sm text-ink/60">
|
|
78
|
+
Old URLs are redirected to new ones. A 301 is created automatically whenever you change a
|
|
79
|
+
page slug in Page SEO.
|
|
80
|
+
</p>
|
|
81
|
+
|
|
82
|
+
<div className="mt-8 border border-ink/10 bg-white p-6">
|
|
83
|
+
<div className="grid gap-3 md:grid-cols-[1fr_1fr_120px_auto] md:items-end">
|
|
84
|
+
<label className="block">
|
|
85
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
|
|
86
|
+
From path
|
|
87
|
+
</span>
|
|
88
|
+
<input
|
|
89
|
+
value={from}
|
|
90
|
+
onChange={(e) => setFrom(e.target.value)}
|
|
91
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
|
|
92
|
+
placeholder="/old-page"
|
|
93
|
+
/>
|
|
94
|
+
</label>
|
|
95
|
+
<label className="block">
|
|
96
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">To path</span>
|
|
97
|
+
<input
|
|
98
|
+
value={to}
|
|
99
|
+
onChange={(e) => setTo(e.target.value)}
|
|
100
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
|
|
101
|
+
placeholder="/new-page"
|
|
102
|
+
/>
|
|
103
|
+
</label>
|
|
104
|
+
<label className="block">
|
|
105
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Status</span>
|
|
106
|
+
<select
|
|
107
|
+
value={status}
|
|
108
|
+
onChange={(e) => setStatus(Number(e.target.value))}
|
|
109
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm"
|
|
110
|
+
>
|
|
111
|
+
<option value={301}>301</option>
|
|
112
|
+
<option value={302}>302</option>
|
|
113
|
+
</select>
|
|
114
|
+
</label>
|
|
115
|
+
<button
|
|
116
|
+
onClick={onAdd}
|
|
117
|
+
disabled={busy}
|
|
118
|
+
className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-sm text-white disabled:opacity-50"
|
|
119
|
+
>
|
|
120
|
+
<Plus className="size-4" /> Add
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="mt-8 border border-ink/10 bg-white">
|
|
126
|
+
<table className="w-full text-sm">
|
|
127
|
+
<thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
|
|
128
|
+
<tr>
|
|
129
|
+
<th className="px-4 py-3">From</th>
|
|
130
|
+
<th className="px-4 py-3">To</th>
|
|
131
|
+
<th className="px-4 py-3 w-28">Status</th>
|
|
132
|
+
<th className="px-4 py-3 w-44">Created</th>
|
|
133
|
+
<th className="px-4 py-3 w-40"></th>
|
|
134
|
+
</tr>
|
|
135
|
+
</thead>
|
|
136
|
+
<tbody>
|
|
137
|
+
{rows.map((r) => (
|
|
138
|
+
<Row key={r.id} row={r} onSave={onUpdate} onDelete={onDelete} />
|
|
139
|
+
))}
|
|
140
|
+
{rows.length === 0 && (
|
|
141
|
+
<tr>
|
|
142
|
+
<td colSpan={5} className="px-4 py-8 text-center text-ink/50">
|
|
143
|
+
No redirects yet.
|
|
144
|
+
</td>
|
|
145
|
+
</tr>
|
|
146
|
+
)}
|
|
147
|
+
</tbody>
|
|
148
|
+
</table>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function Row({
|
|
155
|
+
row,
|
|
156
|
+
onSave,
|
|
157
|
+
onDelete,
|
|
158
|
+
}: {
|
|
159
|
+
row: RedirectRow;
|
|
160
|
+
onSave: (r: { id: string; from_path: string; to_path: string; status: number }) => void;
|
|
161
|
+
onDelete: (id: string) => void;
|
|
162
|
+
}) {
|
|
163
|
+
const [from, setFrom] = useState(row.from_path);
|
|
164
|
+
const [to, setTo] = useState(row.to_path);
|
|
165
|
+
const [status, setStatus] = useState(row.status);
|
|
166
|
+
const dirty = from !== row.from_path || to !== row.to_path || status !== row.status;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<tr className="border-t border-ink/10">
|
|
170
|
+
<td className="px-4 py-2">
|
|
171
|
+
<input
|
|
172
|
+
value={from}
|
|
173
|
+
onChange={(e) => setFrom(e.target.value)}
|
|
174
|
+
className="w-full border border-transparent hover:border-ink/20 px-2 py-1 font-mono text-sm"
|
|
175
|
+
/>
|
|
176
|
+
</td>
|
|
177
|
+
<td className="px-4 py-2">
|
|
178
|
+
<input
|
|
179
|
+
value={to}
|
|
180
|
+
onChange={(e) => setTo(e.target.value)}
|
|
181
|
+
className="w-full border border-transparent hover:border-ink/20 px-2 py-1 font-mono text-sm"
|
|
182
|
+
/>
|
|
183
|
+
</td>
|
|
184
|
+
<td className="px-4 py-2">
|
|
185
|
+
<select
|
|
186
|
+
value={status}
|
|
187
|
+
onChange={(e) => setStatus(Number(e.target.value))}
|
|
188
|
+
className="border border-ink/20 px-2 py-1 text-sm"
|
|
189
|
+
>
|
|
190
|
+
<option value={301}>301</option>
|
|
191
|
+
<option value={302}>302</option>
|
|
192
|
+
</select>
|
|
193
|
+
</td>
|
|
194
|
+
<td className="px-4 py-2 whitespace-nowrap text-xs text-ink/60">
|
|
195
|
+
{row.created_at
|
|
196
|
+
? new Date(row.created_at).toLocaleString("sv-SE", {
|
|
197
|
+
year: "numeric",
|
|
198
|
+
month: "2-digit",
|
|
199
|
+
day: "2-digit",
|
|
200
|
+
hour: "2-digit",
|
|
201
|
+
minute: "2-digit",
|
|
202
|
+
})
|
|
203
|
+
: "—"}
|
|
204
|
+
</td>
|
|
205
|
+
<td className="px-4 py-2 text-right">
|
|
206
|
+
{dirty && (
|
|
207
|
+
<button
|
|
208
|
+
onClick={() => onSave({ id: row.id, from_path: from, to_path: to, status })}
|
|
209
|
+
className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
|
|
210
|
+
>
|
|
211
|
+
<Save className="size-3.5" /> Save
|
|
212
|
+
</button>
|
|
213
|
+
)}
|
|
214
|
+
<button
|
|
215
|
+
onClick={() => onDelete(row.id)}
|
|
216
|
+
className="inline-flex items-center gap-1 text-red-600 hover:underline"
|
|
217
|
+
>
|
|
218
|
+
<Trash2 className="size-3.5" /> Delete
|
|
219
|
+
</button>
|
|
220
|
+
</td>
|
|
221
|
+
</tr>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Shapes the shared Media Library screen needs from the host. */
|
|
2
|
+
|
|
3
|
+
export type MediaLibraryItem = {
|
|
4
|
+
path: string;
|
|
5
|
+
name: string;
|
|
6
|
+
url: string;
|
|
7
|
+
size: number;
|
|
8
|
+
contentType?: string | null;
|
|
9
|
+
uploadedAt?: string | null;
|
|
10
|
+
updatedAt?: string | null;
|
|
11
|
+
uploadedBy?: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type MediaTrashItem = {
|
|
15
|
+
id: string;
|
|
16
|
+
originalPath: string;
|
|
17
|
+
url: string;
|
|
18
|
+
size: number;
|
|
19
|
+
contentType?: string | null;
|
|
20
|
+
deletedAt: string;
|
|
21
|
+
deletedByEmail?: string | null;
|
|
22
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imperative poster-frame picker for video uploads.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a small dialog on demand so any upload surface (Media Library, block
|
|
5
|
+
* media picker) can ask the editor which frame to use as the video poster.
|
|
6
|
+
* Resolves with the chosen time in seconds, or `null` to keep the automatic
|
|
7
|
+
* frame (~10% into the clip).
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState } from "react";
|
|
10
|
+
import { createRoot } from "react-dom/client";
|
|
11
|
+
|
|
12
|
+
function formatTime(s: number) {
|
|
13
|
+
if (!Number.isFinite(s)) return "0:00";
|
|
14
|
+
const m = Math.floor(s / 60);
|
|
15
|
+
const sec = Math.floor(s % 60);
|
|
16
|
+
return `${m}:${String(sec).padStart(2, "0")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function PosterDialog({ file, onDone }: { file: File; onDone: (seconds: number | null) => void }) {
|
|
20
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
21
|
+
const [url] = useState(() => URL.createObjectURL(file));
|
|
22
|
+
const [duration, setDuration] = useState(0);
|
|
23
|
+
const [time, setTime] = useState(0);
|
|
24
|
+
|
|
25
|
+
useEffect(() => () => URL.revokeObjectURL(url), [url]);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const onKey = (e: KeyboardEvent) => {
|
|
29
|
+
if (e.key === "Escape") onDone(null);
|
|
30
|
+
};
|
|
31
|
+
window.addEventListener("keydown", onKey);
|
|
32
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
33
|
+
}, [onDone]);
|
|
34
|
+
|
|
35
|
+
const seek = (v: number) => {
|
|
36
|
+
setTime(v);
|
|
37
|
+
if (videoRef.current) videoRef.current.currentTime = v;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-ink/70 p-4">
|
|
42
|
+
<div className="w-full max-w-2xl border border-ink/15 bg-white shadow-xl">
|
|
43
|
+
<header className="flex items-center justify-between border-b border-ink/10 px-4 py-3">
|
|
44
|
+
<div>
|
|
45
|
+
<h2 className="text-sm font-medium text-ink">Choose poster frame</h2>
|
|
46
|
+
<p className="text-[0.7rem] text-ink/55">{file.name}</p>
|
|
47
|
+
</div>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => onDone(null)}
|
|
51
|
+
className="text-[0.7rem] uppercase tracking-[0.18em] text-ink/50 hover:text-ink"
|
|
52
|
+
>
|
|
53
|
+
Cancel
|
|
54
|
+
</button>
|
|
55
|
+
</header>
|
|
56
|
+
|
|
57
|
+
<div className="space-y-3 p-4">
|
|
58
|
+
<video
|
|
59
|
+
ref={videoRef}
|
|
60
|
+
src={url}
|
|
61
|
+
muted
|
|
62
|
+
playsInline
|
|
63
|
+
preload="auto"
|
|
64
|
+
className="max-h-[50vh] w-full bg-ink/5 object-contain"
|
|
65
|
+
onLoadedMetadata={(e) => {
|
|
66
|
+
const v = e.currentTarget;
|
|
67
|
+
const d = Number.isFinite(v.duration) ? v.duration : 0;
|
|
68
|
+
setDuration(d);
|
|
69
|
+
const start = Math.min(d, d * 0.1);
|
|
70
|
+
setTime(start);
|
|
71
|
+
v.currentTime = start;
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
<input
|
|
75
|
+
type="range"
|
|
76
|
+
min={0}
|
|
77
|
+
max={Math.max(0.1, duration)}
|
|
78
|
+
step={0.05}
|
|
79
|
+
value={time}
|
|
80
|
+
onChange={(e) => seek(Number(e.target.value))}
|
|
81
|
+
className="w-full accent-sea"
|
|
82
|
+
/>
|
|
83
|
+
<div className="flex items-center justify-between text-[0.72rem] text-ink/60">
|
|
84
|
+
<span>
|
|
85
|
+
{formatTime(time)} / {formatTime(duration)}
|
|
86
|
+
</span>
|
|
87
|
+
<span>Drag the slider to the frame you want as the poster image.</span>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<footer className="flex justify-end gap-2 border-t border-ink/10 px-4 py-3">
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => onDone(null)}
|
|
95
|
+
className="border border-ink/15 px-3 py-1.5 text-sm hover:border-ink/40"
|
|
96
|
+
>
|
|
97
|
+
Use automatic frame
|
|
98
|
+
</button>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => onDone(time)}
|
|
102
|
+
className="bg-ink px-4 py-1.5 text-sm text-paper hover:bg-sea"
|
|
103
|
+
>
|
|
104
|
+
Use this frame
|
|
105
|
+
</button>
|
|
106
|
+
</footer>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Ask the editor to pick a poster frame. Returns seconds, or null for auto. */
|
|
113
|
+
export function pickVideoPoster(file: File): Promise<number | null> {
|
|
114
|
+
if (typeof document === "undefined") return Promise.resolve(null);
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const host = document.createElement("div");
|
|
117
|
+
document.body.appendChild(host);
|
|
118
|
+
const root = createRoot(host);
|
|
119
|
+
const done = (seconds: number | null) => {
|
|
120
|
+
resolve(seconds);
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
root.unmount();
|
|
123
|
+
host.remove();
|
|
124
|
+
}, 0);
|
|
125
|
+
};
|
|
126
|
+
root.render(<PosterDialog file={file} onDone={done} />);
|
|
127
|
+
});
|
|
128
|
+
}
|