@drawnagency/primitives 0.1.105 → 0.1.107
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/dist/auth/cookies.d.ts +21 -1
- package/dist/auth/cookies.d.ts.map +1 -1
- package/dist/auth/index.d.ts +2 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/auth/types.d.ts +28 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/{chunk-PTOLUMGV.js → chunk-4RNF2DRG.js} +1 -0
- package/dist/{chunk-MUTHF2GT.js → chunk-5IZ2KTMU.js} +2 -2
- package/dist/{chunk-LZKVV4K2.js → chunk-6P4CJCXB.js} +7 -1
- package/dist/{chunk-CZLYECHC.js → chunk-AB3XVXI3.js} +4 -4
- package/dist/chunk-B7VBSE5X.js +12 -0
- package/dist/{chunk-BGHWUJFV.js → chunk-BOZUEKWL.js} +107 -171
- package/dist/{chunk-TGNS7D4F.js → chunk-CPTFFLK2.js} +1 -1
- package/dist/{chunk-26ALJO4K.js → chunk-D6TLUVOP.js} +1 -1
- package/dist/{chunk-EM5DFHDZ.js → chunk-GEENI3OC.js} +13 -4
- package/dist/chunk-GGXZZ77Z.js +70 -0
- package/dist/{chunk-X4PRBGB3.js → chunk-IPKZ5FU6.js} +83 -33
- package/dist/{chunk-CXQWLG6X.js → chunk-JIQBLG6Y.js} +23 -20
- package/dist/{chunk-EWNDBKYK.js → chunk-LUAI5DWR.js} +1 -1
- package/dist/chunk-NLQ5F3RQ.js +43 -0
- package/dist/{chunk-3COSXTOY.js → chunk-XBYJQ5RS.js} +1 -1
- package/dist/{chunk-WA6QPDJI.js → chunk-ZHHJVKU7.js} +6 -6
- package/dist/collab/rebase.js +2 -1
- package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +8 -7
- package/dist/components/primitives/index.js +7 -6
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +8 -7
- package/dist/components/sections/register-schemas.js +13 -12
- package/dist/components/sections/register.js +13 -12
- package/dist/components/shared/Navigation.d.ts +16 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/SectionSummaryRow.d.ts +24 -0
- package/dist/components/shared/SectionSummaryRow.d.ts.map +1 -0
- package/dist/components/shell/AudienceAddForm.d.ts +4 -1
- package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
- package/dist/components/shell/AudienceDetailsPanel.d.ts +28 -0
- package/dist/components/shell/AudienceDetailsPanel.d.ts.map +1 -0
- package/dist/components/shell/AudienceRow.d.ts +15 -1
- package/dist/components/shell/AudienceRow.d.ts.map +1 -1
- package/dist/components/shell/CredentialList.d.ts +18 -0
- package/dist/components/shell/CredentialList.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/SectionTypePicker.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
- package/dist/components/shell/ViewerLoginForm.d.ts +5 -1
- package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
- package/dist/index.js +47 -29
- package/dist/lib/audience-overview.d.ts +53 -0
- package/dist/lib/audience-overview.d.ts.map +1 -0
- package/dist/lib/audience-secret.d.ts +11 -0
- package/dist/lib/audience-secret.d.ts.map +1 -0
- package/dist/lib/audience-secret.js +70 -0
- package/dist/lib/container-ops.d.ts.map +1 -1
- package/dist/lib/events.d.ts +3 -0
- package/dist/lib/events.d.ts.map +1 -1
- package/dist/lib/index-ops.js +3 -2
- package/dist/lib/index.js +13 -12
- package/dist/lib/insert-menu.d.ts +7 -3
- package/dist/lib/insert-menu.d.ts.map +1 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/thumb-views.d.ts +17 -0
- package/dist/lib/thumb-views.d.ts.map +1 -0
- package/dist/media/index.js +4 -3
- package/dist/schemas/audience.d.ts +29 -0
- package/dist/schemas/audience.d.ts.map +1 -1
- package/dist/schemas/audience.js +24 -0
- package/dist/schemas/auth.d.ts +1 -0
- package/dist/schemas/auth.d.ts.map +1 -1
- package/dist/schemas/auth.js +1 -1
- package/dist/schemas/index.js +28 -12
- package/dist/schemas/site-config.d.ts +1 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/types/database.d.ts +59 -5
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/auth/cookies.ts +37 -4
- package/src/auth/index.ts +2 -1
- package/src/auth/types.ts +32 -0
- package/src/components/editor/SectionOrderingModal.tsx +8 -73
- package/src/components/sections/Container/Container.tsx +12 -2
- package/src/components/sections/IconList/IconList.tsx +75 -22
- package/src/components/shared/Navigation.tsx +113 -7
- package/src/components/shared/SectionSummaryRow.tsx +73 -0
- package/src/components/shell/AudienceAddForm.tsx +18 -9
- package/src/components/shell/AudienceDetailsPanel.tsx +263 -0
- package/src/components/shell/AudienceRow.tsx +55 -13
- package/src/components/shell/CredentialList.tsx +285 -0
- package/src/components/shell/EditorShell.tsx +98 -5
- package/src/components/shell/MediaLibraryModal.tsx +42 -5
- package/src/components/shell/SectionTypePicker.tsx +28 -2
- package/src/components/shell/SiteSettingsViewerAccess.tsx +57 -6
- package/src/components/shell/ViewerLoginForm.tsx +35 -15
- package/src/lib/audience-overview.ts +110 -0
- package/src/lib/audience-secret.ts +99 -0
- package/src/lib/container-ops.ts +6 -3
- package/src/lib/events.ts +3 -0
- package/src/lib/insert-menu.ts +38 -4
- package/src/lib/nav.ts +6 -2
- package/src/lib/thumb-views.ts +28 -0
- package/src/schemas/audience.ts +52 -0
- package/src/schemas/auth.ts +6 -0
- package/src/schemas/site-config.ts +1 -1
- package/src/types/database.ts +59 -5
- package/dist/chunk-ZDQ3TT47.js +0 -30
|
@@ -4,6 +4,8 @@ import { Input } from "../shared/Input";
|
|
|
4
4
|
import { PasswordInput } from "../shared/PasswordInput";
|
|
5
5
|
import { Button } from "../shared/Button";
|
|
6
6
|
import { ColorPicker } from "../shared/ColorPicker";
|
|
7
|
+
import { CredentialList } from "./CredentialList";
|
|
8
|
+
import type { ViewerCredential } from "../../schemas/audience";
|
|
7
9
|
|
|
8
10
|
interface Props {
|
|
9
11
|
audience: Audience;
|
|
@@ -11,6 +13,15 @@ interface Props {
|
|
|
11
13
|
canMutate: boolean;
|
|
12
14
|
onSave: (patch: { displayName?: string; color?: string | null; password?: string }) => Promise<void>;
|
|
13
15
|
onDelete: () => Promise<void>;
|
|
16
|
+
/** Named sign-ins for this audience. Undefined in `.env`/audience mode, where
|
|
17
|
+
* the row keeps its single audience-level password field. */
|
|
18
|
+
credentials?: ViewerCredential[];
|
|
19
|
+
credentialActions?: {
|
|
20
|
+
create: (data: { username: string; label: string | null; password: string }) => Promise<void>;
|
|
21
|
+
setPassword: (username: string, password: string) => Promise<void>;
|
|
22
|
+
setLabel: (username: string, label: string | null) => Promise<void>;
|
|
23
|
+
delete: (username: string) => Promise<void>;
|
|
24
|
+
};
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
const PLACEHOLDER_COLOR = "#94a3b8";
|
|
@@ -21,7 +32,12 @@ export function AudienceRow({
|
|
|
21
32
|
canMutate,
|
|
22
33
|
onSave,
|
|
23
34
|
onDelete,
|
|
35
|
+
credentials,
|
|
36
|
+
credentialActions,
|
|
24
37
|
}: Props) {
|
|
38
|
+
// Credential mode: access comes from named sign-ins, so the row's own password
|
|
39
|
+
// field is gone. The two are never shown together.
|
|
40
|
+
const credentialMode = Boolean(credentials && credentialActions);
|
|
25
41
|
const [isEditing, setIsEditing] = useState(false);
|
|
26
42
|
const [draftDisplayName, setDraftDisplayName] = useState(audience.displayName);
|
|
27
43
|
const [draftColor, setDraftColor] = useState(audience.color ?? PLACEHOLDER_COLOR);
|
|
@@ -76,7 +92,13 @@ export function AudienceRow({
|
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
async function remove() {
|
|
79
|
-
|
|
95
|
+
const signIns = audience.credentialCount;
|
|
96
|
+
if (!confirm(
|
|
97
|
+
`Remove audience "${audience.displayName}"?` +
|
|
98
|
+
(signIns > 0
|
|
99
|
+
? `\n\nIts ${signIns === 1 ? "sign-in" : `${signIns} sign-ins`} will be removed too, locking out anyone using them.`
|
|
100
|
+
: ""),
|
|
101
|
+
)) return;
|
|
80
102
|
try {
|
|
81
103
|
await onDelete();
|
|
82
104
|
} catch (e) {
|
|
@@ -95,17 +117,21 @@ export function AudienceRow({
|
|
|
95
117
|
value={draftDisplayName}
|
|
96
118
|
onChange={setDraftDisplayName}
|
|
97
119
|
/>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
{!credentialMode && (
|
|
121
|
+
<>
|
|
122
|
+
<PasswordInput
|
|
123
|
+
label="New password"
|
|
124
|
+
value={draftPassword}
|
|
125
|
+
onChange={setDraftPassword}
|
|
126
|
+
autoComplete="new-password"
|
|
127
|
+
placeholder={audience.hasPassword ? "Leave blank to keep current" : "Required"}
|
|
128
|
+
showToggle
|
|
129
|
+
/>
|
|
130
|
+
<span className="block text-[11px] text-base-contrast-light">
|
|
131
|
+
Passwords are hashed — the existing one can't be recovered, only replaced.
|
|
132
|
+
</span>
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
109
135
|
</div>
|
|
110
136
|
</div>
|
|
111
137
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
@@ -148,7 +174,11 @@ export function AudienceRow({
|
|
|
148
174
|
</span>
|
|
149
175
|
)}
|
|
150
176
|
</span>
|
|
151
|
-
{
|
|
177
|
+
{credentialMode ? (
|
|
178
|
+
<span className="text-xs text-base-contrast-light">
|
|
179
|
+
{audience.credentialCount === 1 ? "1 sign-in" : `${audience.credentialCount} sign-ins`}
|
|
180
|
+
</span>
|
|
181
|
+
) : audience.hasPassword ? (
|
|
152
182
|
<span
|
|
153
183
|
aria-label="Password is set"
|
|
154
184
|
title="Passwords are hashed and can't be shown. Edit to set a new one."
|
|
@@ -166,6 +196,18 @@ export function AudienceRow({
|
|
|
166
196
|
<Button variant="ghost" tone="destructive" onClick={remove}>Remove</Button>
|
|
167
197
|
)}
|
|
168
198
|
</div>
|
|
199
|
+
|
|
200
|
+
{credentials && credentialActions && (
|
|
201
|
+
<CredentialList
|
|
202
|
+
audienceName={audience.displayName}
|
|
203
|
+
credentials={credentials}
|
|
204
|
+
canMutate={canMutate}
|
|
205
|
+
onCreate={credentialActions.create}
|
|
206
|
+
onSetPassword={credentialActions.setPassword}
|
|
207
|
+
onSetLabel={credentialActions.setLabel}
|
|
208
|
+
onDelete={credentialActions.delete}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
169
211
|
</div>
|
|
170
212
|
);
|
|
171
213
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Eye, Copy, Check } from "lucide-react";
|
|
3
|
+
import { Input } from "../shared/Input";
|
|
4
|
+
import { PasswordInput } from "../shared/PasswordInput";
|
|
5
|
+
import { Button } from "../shared/Button";
|
|
6
|
+
import {
|
|
7
|
+
ViewerUsernameSchema,
|
|
8
|
+
VIEWER_PASSWORD_MIN_LENGTH,
|
|
9
|
+
normalizeUsername,
|
|
10
|
+
slugifyAudienceName,
|
|
11
|
+
} from "../../schemas/audience";
|
|
12
|
+
import type { ViewerCredential } from "../../schemas/audience";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
audienceName: string;
|
|
16
|
+
credentials: ViewerCredential[];
|
|
17
|
+
canMutate: boolean;
|
|
18
|
+
onCreate: (data: { username: string; label: string | null; password: string }) => Promise<void>;
|
|
19
|
+
onSetPassword: (username: string, password: string) => Promise<void>;
|
|
20
|
+
onSetLabel: (username: string, label: string | null) => Promise<void>;
|
|
21
|
+
onDelete: (username: string) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function CopyableSecret({ value }: { value: string }) {
|
|
25
|
+
const [copied, setCopied] = useState(false);
|
|
26
|
+
return (
|
|
27
|
+
<span className="flex items-center gap-2">
|
|
28
|
+
<code className="rounded bg-base-accent px-2 py-1 font-mono text-xs text-base-contrast select-all">{value}</code>
|
|
29
|
+
<Button
|
|
30
|
+
variant="ghost"
|
|
31
|
+
aria-label="Copy password"
|
|
32
|
+
onClick={async () => {
|
|
33
|
+
try {
|
|
34
|
+
await navigator.clipboard.writeText(value);
|
|
35
|
+
setCopied(true);
|
|
36
|
+
setTimeout(() => setCopied(false), 2000);
|
|
37
|
+
} catch {
|
|
38
|
+
// Clipboard blocked — the value is on screen and selectable.
|
|
39
|
+
}
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{copied ? <Check className="h-3.5 w-3.5" aria-hidden /> : <Copy className="h-3.5 w-3.5" aria-hidden />}
|
|
43
|
+
</Button>
|
|
44
|
+
</span>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function CredentialRow({
|
|
49
|
+
credential, canMutate, onSetPassword, onSetLabel, onDelete,
|
|
50
|
+
}: {
|
|
51
|
+
credential: ViewerCredential;
|
|
52
|
+
canMutate: boolean;
|
|
53
|
+
onSetPassword: (username: string, password: string) => Promise<void>;
|
|
54
|
+
onSetLabel: (username: string, label: string | null) => Promise<void>;
|
|
55
|
+
onDelete: (username: string) => Promise<void>;
|
|
56
|
+
}) {
|
|
57
|
+
const [editing, setEditing] = useState(false);
|
|
58
|
+
const [label, setLabel] = useState(credential.label ?? "");
|
|
59
|
+
const [password, setPassword] = useState("");
|
|
60
|
+
const [revealed, setRevealed] = useState<string | null>(null);
|
|
61
|
+
const [busy, setBusy] = useState(false);
|
|
62
|
+
const [error, setError] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
async function reveal() {
|
|
65
|
+
setError(null);
|
|
66
|
+
setBusy(true);
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch("/api/auth/viewer-password", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ username: credential.username }),
|
|
72
|
+
});
|
|
73
|
+
const body = await res.json().catch(() => ({}));
|
|
74
|
+
if (body.status === "revealed") setRevealed(body.password);
|
|
75
|
+
else if (body.status === "unavailable") {
|
|
76
|
+
setError("Hidden. Set a new password to view here.");
|
|
77
|
+
} else setError(body.error ?? "Couldn't load the password.");
|
|
78
|
+
} catch {
|
|
79
|
+
setError("Network error");
|
|
80
|
+
} finally {
|
|
81
|
+
setBusy(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function save() {
|
|
86
|
+
setError(null);
|
|
87
|
+
if (password.length > 0 && password.length < VIEWER_PASSWORD_MIN_LENGTH) {
|
|
88
|
+
setError(`Password must be at least ${VIEWER_PASSWORD_MIN_LENGTH} characters`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setBusy(true);
|
|
92
|
+
try {
|
|
93
|
+
const nextLabel = label.trim().length > 0 ? label.trim() : null;
|
|
94
|
+
if (nextLabel !== (credential.label ?? null)) await onSetLabel(credential.username, nextLabel);
|
|
95
|
+
if (password.length > 0) {
|
|
96
|
+
await onSetPassword(credential.username, password);
|
|
97
|
+
// Show what was just set, so it can be shared without a second lookup.
|
|
98
|
+
setRevealed(password);
|
|
99
|
+
}
|
|
100
|
+
setPassword("");
|
|
101
|
+
setEditing(false);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
setError(e instanceof Error ? e.message : "Save failed");
|
|
104
|
+
} finally {
|
|
105
|
+
setBusy(false);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function remove() {
|
|
110
|
+
if (!confirm(
|
|
111
|
+
`Remove the sign-in "${credential.username}"?\n\n` +
|
|
112
|
+
"Anyone using it will be locked out — though a browser already signed in " +
|
|
113
|
+
"may keep access for up to 24 hours, until its session expires.",
|
|
114
|
+
)) return;
|
|
115
|
+
setError(null);
|
|
116
|
+
try {
|
|
117
|
+
await onDelete(credential.username);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
setError(e instanceof Error ? e.message : "Remove failed");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<li className="rounded border border-base-200 px-3 py-2">
|
|
125
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
126
|
+
<span className="min-w-0 flex-1">
|
|
127
|
+
<span className="block truncate font-mono text-sm text-base-contrast">{credential.username}</span>
|
|
128
|
+
{credential.label && !editing && (
|
|
129
|
+
<span className="block truncate text-[11px] text-base-contrast-light">{credential.label}</span>
|
|
130
|
+
)}
|
|
131
|
+
</span>
|
|
132
|
+
|
|
133
|
+
{revealed ? (
|
|
134
|
+
<CopyableSecret value={revealed} />
|
|
135
|
+
) : (
|
|
136
|
+
<Button variant="ghost" onClick={reveal} isLoading={busy && !editing} loadingLabel="...">
|
|
137
|
+
<span className="inline-flex items-center gap-1.5">
|
|
138
|
+
<Eye className="h-3.5 w-3.5" aria-hidden />
|
|
139
|
+
Show
|
|
140
|
+
</span>
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
{revealed && <Button variant="ghost" onClick={() => setRevealed(null)}>Hide</Button>}
|
|
144
|
+
|
|
145
|
+
{canMutate && !editing && <Button variant="ghost" onClick={() => setEditing(true)}>Edit</Button>}
|
|
146
|
+
{canMutate && !editing && (
|
|
147
|
+
<Button variant="ghost" tone="destructive" onClick={remove}>Remove</Button>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{editing && (
|
|
152
|
+
<div className="mt-2 space-y-2">
|
|
153
|
+
<Input label="Label" value={label} onChange={setLabel} placeholder="Login Name" />
|
|
154
|
+
<PasswordInput
|
|
155
|
+
label="New password"
|
|
156
|
+
value={password}
|
|
157
|
+
onChange={setPassword}
|
|
158
|
+
autoComplete="new-password"
|
|
159
|
+
placeholder="Leave blank to keep the current one"
|
|
160
|
+
showToggle
|
|
161
|
+
/>
|
|
162
|
+
<div className="flex justify-end gap-2">
|
|
163
|
+
<Button variant="secondary" onClick={() => { setEditing(false); setPassword(""); setLabel(credential.label ?? ""); setError(null); }} disabled={busy}>
|
|
164
|
+
Cancel
|
|
165
|
+
</Button>
|
|
166
|
+
<Button variant="primary" onClick={save} isLoading={busy} loadingLabel="Saving...">Save</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
|
172
|
+
</li>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function AddForm({ audienceName, onCreate, onCancel }: {
|
|
177
|
+
audienceName: string;
|
|
178
|
+
onCreate: Props["onCreate"];
|
|
179
|
+
onCancel: () => void;
|
|
180
|
+
}) {
|
|
181
|
+
const [label, setLabel] = useState("");
|
|
182
|
+
const [username, setUsername] = useState("");
|
|
183
|
+
// The username follows the label until the editor takes it over, so the
|
|
184
|
+
// common case is a single field and the slug stays predictable. Once they
|
|
185
|
+
// type in the username it stops tracking — otherwise their edit would be
|
|
186
|
+
// silently overwritten by the next keystroke in the label.
|
|
187
|
+
const [usernameOverridden, setUsernameOverridden] = useState(false);
|
|
188
|
+
const [password, setPassword] = useState("");
|
|
189
|
+
const [error, setError] = useState<string | null>(null);
|
|
190
|
+
const [busy, setBusy] = useState(false);
|
|
191
|
+
|
|
192
|
+
const derivedUsername = usernameOverridden ? username : slugifyAudienceName(label);
|
|
193
|
+
|
|
194
|
+
function editUsername(value: string) {
|
|
195
|
+
setUsernameOverridden(true);
|
|
196
|
+
setUsername(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function submit() {
|
|
200
|
+
setError(null);
|
|
201
|
+
const normalized = normalizeUsername(derivedUsername);
|
|
202
|
+
if (normalized.length === 0) {
|
|
203
|
+
setError("Add a label — the username is generated from it");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const parsed = ViewerUsernameSchema.safeParse(normalized);
|
|
207
|
+
if (!parsed.success) {
|
|
208
|
+
setError("Username: lowercase letters, numbers, dots, dashes and underscores only");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (password.length < VIEWER_PASSWORD_MIN_LENGTH) {
|
|
212
|
+
setError(`Password must be at least ${VIEWER_PASSWORD_MIN_LENGTH} characters`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
setBusy(true);
|
|
216
|
+
try {
|
|
217
|
+
await onCreate({ username: normalized, label: label.trim() || null, password });
|
|
218
|
+
onCancel();
|
|
219
|
+
} catch (e) {
|
|
220
|
+
setError(e instanceof Error ? e.message : "Add failed");
|
|
221
|
+
} finally {
|
|
222
|
+
setBusy(false);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<li className="space-y-2 rounded border border-base-200 px-3 py-3">
|
|
228
|
+
<Input label="Label" value={label} onChange={setLabel} placeholder="Login Name" />
|
|
229
|
+
<Input
|
|
230
|
+
label="Username"
|
|
231
|
+
value={derivedUsername}
|
|
232
|
+
onChange={editUsername}
|
|
233
|
+
autoCapitalize="none"
|
|
234
|
+
spellCheck={false}
|
|
235
|
+
/>
|
|
236
|
+
<p className="text-[11px] text-base-contrast-light">
|
|
237
|
+
Generated from the label — change it if you want something different. This is what they type to sign in, and it can't be renamed later.
|
|
238
|
+
</p>
|
|
239
|
+
<PasswordInput label="Password" value={password} onChange={setPassword} autoComplete="new-password" showToggle />
|
|
240
|
+
<p className="text-[11px] text-base-contrast-light">
|
|
241
|
+
Signs in to <span className="font-medium">{audienceName}</span> and sees exactly what that audience sees.
|
|
242
|
+
You can look this password up again later.
|
|
243
|
+
</p>
|
|
244
|
+
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
245
|
+
<div className="flex justify-end gap-2">
|
|
246
|
+
<Button variant="secondary" onClick={onCancel} disabled={busy}>Cancel</Button>
|
|
247
|
+
<Button variant="primary" onClick={submit} isLoading={busy} loadingLabel="Adding...">Add</Button>
|
|
248
|
+
</div>
|
|
249
|
+
</li>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The named sign-ins for one audience, inside its Site Settings row. */
|
|
254
|
+
export function CredentialList({
|
|
255
|
+
audienceName, credentials, canMutate, onCreate, onSetPassword, onSetLabel, onDelete,
|
|
256
|
+
}: Props) {
|
|
257
|
+
const [adding, setAdding] = useState(false);
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div className="mt-2 space-y-2 border-t border-base-200 pt-2">
|
|
261
|
+
<p className="text-[11px] font-medium uppercase tracking-wide text-base-contrast-light">Sign-ins</p>
|
|
262
|
+
<ul className="space-y-2">
|
|
263
|
+
{credentials.map((c) => (
|
|
264
|
+
<CredentialRow
|
|
265
|
+
key={c.username}
|
|
266
|
+
credential={c}
|
|
267
|
+
canMutate={canMutate}
|
|
268
|
+
onSetPassword={onSetPassword}
|
|
269
|
+
onSetLabel={onSetLabel}
|
|
270
|
+
onDelete={onDelete}
|
|
271
|
+
/>
|
|
272
|
+
))}
|
|
273
|
+
{credentials.length === 0 && !adding && (
|
|
274
|
+
<li className="text-xs text-base-contrast-light">
|
|
275
|
+
No sign-ins yet — nobody can enter as this audience.
|
|
276
|
+
</li>
|
|
277
|
+
)}
|
|
278
|
+
{adding && <AddForm audienceName={audienceName} onCreate={onCreate} onCancel={() => setAdding(false)} />}
|
|
279
|
+
</ul>
|
|
280
|
+
{canMutate && !adding && (
|
|
281
|
+
<Button variant="secondary" onClick={() => setAdding(true)}>Add sign-in</Button>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -93,7 +93,11 @@ import { generateSiteNav } from "../../lib/nav";
|
|
|
93
93
|
import { deriveSiteDocuments, type SiteDocument } from "../../lib/documents";
|
|
94
94
|
import { setSiteDocuments, closeDocFile } from "../docfile/doc-file-state";
|
|
95
95
|
import { pagesInNavOrder, moveNavEntry, addGroup, renameGroup, setGroupStartCollapsed, deleteGroup, type NavLocation } from "../../lib/nav-tree";
|
|
96
|
-
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
|
|
96
|
+
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent, audienceDetailsEvent } from "../../lib/events";
|
|
97
|
+
import { AudienceDetailsPanel, type AudienceDetail } from "./AudienceDetailsPanel";
|
|
98
|
+
import { buildAudienceOverview, type SectionDisplay } from "../../lib/audience-overview";
|
|
99
|
+
import { toThumbViews } from "../../lib/thumb-views";
|
|
100
|
+
import type { ViewerCredential } from "../../schemas/audience";
|
|
97
101
|
import {
|
|
98
102
|
homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
|
|
99
103
|
reorderVisibleSectionInPage, audiencePasses,
|
|
@@ -263,6 +267,13 @@ export default function EditorShell({
|
|
|
263
267
|
// fully landed when part of it was refused.
|
|
264
268
|
const [saveWarnings, setSaveWarnings] = useState<string[]>([]);
|
|
265
269
|
const [activePageId, setActivePageId] = useState<string | null>(null);
|
|
270
|
+
// The Audience Details surface, opened from the nav. Rendered INSTEAD of the
|
|
271
|
+
// section canvas so the toolbar, unsaved drafts and modals all stay mounted —
|
|
272
|
+
// an editor reviewing access never leaves the editor.
|
|
273
|
+
const [showAudienceDetails, setShowAudienceDetails] = useState(false);
|
|
274
|
+
// null = the auth provider has no named sign-ins (`.env` mode → route 501s),
|
|
275
|
+
// which is also what turns the reveal buttons off.
|
|
276
|
+
const [viewerCredentials, setViewerCredentials] = useState<ViewerCredential[] | null>(null);
|
|
266
277
|
const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
|
|
267
278
|
// Set by recoverConflict when a rebase surfaces overlapping edits; drives the
|
|
268
279
|
// keep-mine/take-theirs dialog. `resolve` is the promise resolver recovery
|
|
@@ -369,6 +380,41 @@ export default function EditorShell({
|
|
|
369
380
|
return resolvedActivePage.order.map((id) => sectionsById[id]).filter((s): s is LoadedSection => !!s);
|
|
370
381
|
}, [resolvedActivePage, sectionsById]);
|
|
371
382
|
|
|
383
|
+
// Same model the /audiences route builds server-side, from the shell's own
|
|
384
|
+
// live state — so an editor sees their SAVED draft's access, not the published
|
|
385
|
+
// snapshot the route would show.
|
|
386
|
+
const audienceDetails = useMemo<AudienceDetail[]>(() => {
|
|
387
|
+
if (!showAudienceDetails) return [];
|
|
388
|
+
const cache = new Map<string, SectionDisplay>();
|
|
389
|
+
const display = (sectionId: string): SectionDisplay => {
|
|
390
|
+
const cached = cache.get(sectionId);
|
|
391
|
+
if (cached) return cached;
|
|
392
|
+
const loaded = sectionsById[sectionId];
|
|
393
|
+
const type = loaded?.section.type ?? siteIndex.sections[sectionId]?.type ?? "unknown";
|
|
394
|
+
const definition = getSection(type);
|
|
395
|
+
const resolved: SectionDisplay = {
|
|
396
|
+
label: loaded
|
|
397
|
+
? definition?.getLabel?.(loaded.section) ?? definition?.label ?? type
|
|
398
|
+
: definition?.label ?? type,
|
|
399
|
+
typeLabel: definition?.label ?? type,
|
|
400
|
+
thumbnails: loaded ? toThumbViews(definition?.getThumbnails?.(loaded.section) ?? [], mediaManifest) : [],
|
|
401
|
+
};
|
|
402
|
+
cache.set(sectionId, resolved);
|
|
403
|
+
return resolved;
|
|
404
|
+
};
|
|
405
|
+
return audiences.map((a) => ({
|
|
406
|
+
name: a.name,
|
|
407
|
+
displayName: a.displayName,
|
|
408
|
+
color: a.color,
|
|
409
|
+
isDefault: a.isDefault,
|
|
410
|
+
hasPassword: a.hasPassword,
|
|
411
|
+
credentials: (viewerCredentials ?? [])
|
|
412
|
+
.filter((c) => c.audienceName === a.name)
|
|
413
|
+
.map((c) => ({ username: c.username, label: c.label })),
|
|
414
|
+
overview: buildAudienceOverview(siteIndex, display, a.name),
|
|
415
|
+
}));
|
|
416
|
+
}, [showAudienceDetails, audiences, viewerCredentials, siteIndex, sectionsById, mediaManifest]);
|
|
417
|
+
|
|
372
418
|
const pageAudiences = useMemo(() => {
|
|
373
419
|
if (!resolvedActivePage || resolvedActivePage.access.length === 0) return audiences;
|
|
374
420
|
return audiences.filter((a) => resolvedActivePage.access.includes(a.name));
|
|
@@ -965,11 +1011,16 @@ export default function EditorShell({
|
|
|
965
1011
|
useEffect(() => {
|
|
966
1012
|
if (!resolvedActivePage) return;
|
|
967
1013
|
const nav = generateSiteNav({
|
|
968
|
-
index: siteIndex, activeSections,
|
|
1014
|
+
index: siteIndex, activeSections,
|
|
1015
|
+
// The Audience Details surface is not a page: clearing the active id drops
|
|
1016
|
+
// the previous page's highlight (and its heading tree), so the sidebar
|
|
1017
|
+
// never shows you in two places at once.
|
|
1018
|
+
activePageId: showAudienceDetails ? null : resolvedActivePage.id,
|
|
1019
|
+
mode: "editor", audience: null,
|
|
969
1020
|
documents: siteDocuments ?? [],
|
|
970
1021
|
});
|
|
971
1022
|
siteNavChangeEvent.dispatch(nav);
|
|
972
|
-
}, [siteIndex, activeSections, resolvedActivePage, siteDocuments]);
|
|
1023
|
+
}, [siteIndex, activeSections, resolvedActivePage, siteDocuments, showAudienceDetails]);
|
|
973
1024
|
|
|
974
1025
|
const goToPage = useCallback((pageId: string) => {
|
|
975
1026
|
const page = pageById(siteIndexRef.current, pageId);
|
|
@@ -986,7 +1037,25 @@ export default function EditorShell({
|
|
|
986
1037
|
}, []);
|
|
987
1038
|
|
|
988
1039
|
// Nav island → shell page selection.
|
|
989
|
-
useEffect(() => pageSelectEvent.listen((id) => goToPage(id)), [goToPage]);
|
|
1040
|
+
useEffect(() => pageSelectEvent.listen((id) => { setShowAudienceDetails(false); goToPage(id); }), [goToPage]);
|
|
1041
|
+
useEffect(() => audienceDetailsEvent.listen(() => setShowAudienceDetails(true)), []);
|
|
1042
|
+
|
|
1043
|
+
// Fetched on open rather than at mount: most editing sessions never look at
|
|
1044
|
+
// this, and the list changes whenever Site Settings adds or removes a sign-in.
|
|
1045
|
+
useEffect(() => {
|
|
1046
|
+
if (!showAudienceDetails) return;
|
|
1047
|
+
let cancelled = false;
|
|
1048
|
+
(async () => {
|
|
1049
|
+
try {
|
|
1050
|
+
const res = await fetch("/api/auth/credentials");
|
|
1051
|
+
if (cancelled) return;
|
|
1052
|
+
setViewerCredentials(res.ok ? (await res.json()).credentials ?? [] : null);
|
|
1053
|
+
} catch {
|
|
1054
|
+
if (!cancelled) setViewerCredentials(null);
|
|
1055
|
+
}
|
|
1056
|
+
})();
|
|
1057
|
+
return () => { cancelled = true; };
|
|
1058
|
+
}, [showAudienceDetails]);
|
|
990
1059
|
|
|
991
1060
|
// Back/forward maps the URL back to the active page (no reload).
|
|
992
1061
|
useEffect(() => {
|
|
@@ -1879,6 +1948,26 @@ export default function EditorShell({
|
|
|
1879
1948
|
document pane) lives here for exactly the same reason: hiding
|
|
1880
1949
|
main would hide the toolbar and FABs too. */}
|
|
1881
1950
|
<div className="bp-band-shift bp-page-content">
|
|
1951
|
+
{showAudienceDetails ? (
|
|
1952
|
+
<div className="py-8">
|
|
1953
|
+
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
|
1954
|
+
<h1 className="text-2xl font-bold text-base-contrast">Audience details</h1>
|
|
1955
|
+
<Button
|
|
1956
|
+
variant="secondary"
|
|
1957
|
+
onClick={() => resolvedActivePage && pageSelectEvent.dispatch(resolvedActivePage.id)}
|
|
1958
|
+
>
|
|
1959
|
+
Back to editing
|
|
1960
|
+
</Button>
|
|
1961
|
+
</div>
|
|
1962
|
+
<p className="mt-2 max-w-2xl text-sm text-base-contrast-light">
|
|
1963
|
+
What each audience can see, and the sign-ins that reach it. Reflects your
|
|
1964
|
+
current working copy — add or remove sign-ins under Site settings → Audiences.
|
|
1965
|
+
</p>
|
|
1966
|
+
<div className="mt-6">
|
|
1967
|
+
<AudienceDetailsPanel audiences={audienceDetails} revealSupported={viewerCredentials !== null} />
|
|
1968
|
+
</div>
|
|
1969
|
+
</div>
|
|
1970
|
+
) : (
|
|
1882
1971
|
<HistoryOrEditorContent
|
|
1883
1972
|
index={siteIndex}
|
|
1884
1973
|
activeSections={activeSections}
|
|
@@ -1907,6 +1996,7 @@ export default function EditorShell({
|
|
|
1907
1996
|
onTakeover={confirmTakeover}
|
|
1908
1997
|
/>
|
|
1909
1998
|
</HistoryOrEditorContent>
|
|
1999
|
+
)}
|
|
1910
2000
|
</div>
|
|
1911
2001
|
<GlobalModal />
|
|
1912
2002
|
<SiteSettingsModal
|
|
@@ -2361,7 +2451,10 @@ function EditorContent({
|
|
|
2361
2451
|
}, [onBlockMove]);
|
|
2362
2452
|
|
|
2363
2453
|
return (
|
|
2364
|
-
|
|
2454
|
+
// `.section-wrapper:first-child` zeroes the 2.25rem that every other section
|
|
2455
|
+
// reserves above itself for its hover chrome, so the first section's controls
|
|
2456
|
+
// would sit under the fixed toolbar. Re-reserve it on the list instead.
|
|
2457
|
+
<div className="pt-9">
|
|
2365
2458
|
{displaySections.map(({ section, meta }, index) => {
|
|
2366
2459
|
const definition = getSection(section.type);
|
|
2367
2460
|
if (!definition) {
|
|
@@ -94,7 +94,7 @@ function DocumentTile({ doc, manage, isSelected, refCount, showFolderChip, onCli
|
|
|
94
94
|
isSelected: boolean;
|
|
95
95
|
refCount: number;
|
|
96
96
|
showFolderChip: boolean;
|
|
97
|
-
onClick: () => void;
|
|
97
|
+
onClick: (e: React.MouseEvent) => void;
|
|
98
98
|
}) {
|
|
99
99
|
return (
|
|
100
100
|
<button
|
|
@@ -262,7 +262,7 @@ function UploadZone({ onUpload, onFolderUpload }: {
|
|
|
262
262
|
|
|
263
263
|
function MediaCard({ item, manage, isSelected, dragIds, onClick, children }: {
|
|
264
264
|
item: MediaItem; manage: boolean; isSelected: boolean;
|
|
265
|
-
dragIds: string[]; onClick: () => void; children: React.ReactNode;
|
|
265
|
+
dragIds: string[]; onClick: (e: React.MouseEvent) => void; children: React.ReactNode;
|
|
266
266
|
}) {
|
|
267
267
|
const drag = useMediaDraggable<HTMLDivElement>({
|
|
268
268
|
enabled: manage,
|
|
@@ -446,7 +446,18 @@ export function MediaLibraryModal({
|
|
|
446
446
|
})
|
|
447
447
|
: [];
|
|
448
448
|
|
|
449
|
+
// Visual order of the item grid: documents render before images, and a CSS
|
|
450
|
+
// grid lays both out in source order, so an index range here is exactly the
|
|
451
|
+
// range a shift-click spans on screen — rows included.
|
|
452
|
+
const orderedIds = [...filteredDocuments.map((doc) => doc.id), ...filtered.map((item) => item.id)];
|
|
453
|
+
|
|
454
|
+
// Anchor for shift-click range selection. Stored as an id, not an index:
|
|
455
|
+
// `filtered`/`filteredDocuments` are recomputed every render, so an index
|
|
456
|
+
// would go stale on search, filter, or folder navigation.
|
|
457
|
+
const rangeAnchorId = useRef<string | null>(null);
|
|
458
|
+
|
|
449
459
|
function toggleSelect(id: string) {
|
|
460
|
+
rangeAnchorId.current = id;
|
|
450
461
|
setSelected((prev) => {
|
|
451
462
|
const next = new Set(prev);
|
|
452
463
|
if (next.has(id)) {
|
|
@@ -458,6 +469,32 @@ export function MediaLibraryModal({
|
|
|
458
469
|
});
|
|
459
470
|
}
|
|
460
471
|
|
|
472
|
+
function selectRangeTo(id: string) {
|
|
473
|
+
const anchor = rangeAnchorId.current;
|
|
474
|
+
const from = anchor === null ? -1 : orderedIds.indexOf(anchor);
|
|
475
|
+
const to = orderedIds.indexOf(id);
|
|
476
|
+
// No usable anchor (never clicked, or it was filtered/deleted away) — fall
|
|
477
|
+
// back to a plain toggle so the click is never a no-op.
|
|
478
|
+
if (from === -1 || to === -1) {
|
|
479
|
+
toggleSelect(id);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const range = orderedIds.slice(Math.min(from, to), Math.max(from, to) + 1);
|
|
483
|
+
// The anchor stays put so the range can be re-extended by shift-clicking
|
|
484
|
+
// again, and the range adds rather than toggles (matching file explorers).
|
|
485
|
+
setSelected((prev) => new Set([...prev, ...range]));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function handleItemClick(id: string, e: React.MouseEvent) {
|
|
489
|
+
if (e.shiftKey) {
|
|
490
|
+
// Shift-click otherwise drags a native text selection across the grid.
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
selectRangeTo(id);
|
|
493
|
+
} else {
|
|
494
|
+
toggleSelect(id);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
461
498
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
462
499
|
|
|
463
500
|
// One selection, two kinds. Everything downstream — delete dispatch, usage
|
|
@@ -837,7 +874,7 @@ export function MediaLibraryModal({
|
|
|
837
874
|
isSelected={selected.has(doc.id)}
|
|
838
875
|
refCount={documentReferenceCountMap[doc.id] ?? 0}
|
|
839
876
|
showFolderChip={filtering && documentsFolderEnabled}
|
|
840
|
-
onClick={() => (manage ?
|
|
877
|
+
onClick={(e) => (manage ? handleItemClick(doc.id, e) : onSelect(doc.id))}
|
|
841
878
|
/>
|
|
842
879
|
))}
|
|
843
880
|
{filtered.map((item) => {
|
|
@@ -853,11 +890,11 @@ export function MediaLibraryModal({
|
|
|
853
890
|
manage={manage}
|
|
854
891
|
isSelected={isSelected}
|
|
855
892
|
dragIds={dragIds}
|
|
856
|
-
onClick={() => {
|
|
893
|
+
onClick={(e) => {
|
|
857
894
|
if (mode === "select") {
|
|
858
895
|
onSelect(item.id);
|
|
859
896
|
} else {
|
|
860
|
-
|
|
897
|
+
handleItemClick(item.id, e);
|
|
861
898
|
}
|
|
862
899
|
}}
|
|
863
900
|
>
|