@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.
@@ -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
+ }