domma-cms 0.50.0 → 0.51.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/CLAUDE.md +6 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/page-editor.html +26 -1
- package/admin/js/views/page-editor.js +25 -25
- package/package.json +1 -1
- package/server/routes/api/pages.js +85 -1
- package/server/routes/public.js +56 -0
- package/server/services/content.js +27 -0
- package/server/services/previewLinks.js +215 -0
- package/server/services/renderer.js +69 -31
package/server/routes/public.js
CHANGED
|
@@ -12,6 +12,7 @@ import {renderPage} from '../services/renderer.js';
|
|
|
12
12
|
import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
|
|
13
13
|
import {checkVisibility} from '../middleware/auth.js';
|
|
14
14
|
import {resolveViewer} from '../services/viewerSession.js';
|
|
15
|
+
import {touchPreviewLink, verifyPreviewToken} from '../services/previewLinks.js';
|
|
15
16
|
import {getPermissionsFor} from '../services/roles.js';
|
|
16
17
|
import {getEffectiveRoles} from '../services/userRoles.js';
|
|
17
18
|
import {hooks} from '../services/hooks.js';
|
|
@@ -81,6 +82,61 @@ export async function publicRoutes(fastify) {
|
|
|
81
82
|
return reply.type('text/plain').send(buildRobotsTxt(getBaseUrl(request)));
|
|
82
83
|
});
|
|
83
84
|
|
|
85
|
+
// Share-link preview: one signed token, one page, no account needed.
|
|
86
|
+
//
|
|
87
|
+
// The token rides in the query string, not the path: Fastify caps a route
|
|
88
|
+
// parameter at 100 characters by default and a signed token is three times
|
|
89
|
+
// that, so `/_preview/:token` silently fell through to the catch-all and
|
|
90
|
+
// 404'd. Raising maxParamLength would fix it only for instances that
|
|
91
|
+
// remember to set it - tests and embedders included - so the query string
|
|
92
|
+
// is the form that cannot come apart.
|
|
93
|
+
//
|
|
94
|
+
// The token names the page, so a holder cannot repoint the link; the
|
|
95
|
+
// server-side index means it can be withdrawn after the fact.
|
|
96
|
+
fastify.get('/_preview', async (request, reply) => {
|
|
97
|
+
// Referrer-Policy matters more here than anywhere else on the site: the
|
|
98
|
+
// credential is IN the URL, so a default referrer would hand the whole
|
|
99
|
+
// token to every third-party asset the page loads.
|
|
100
|
+
reply.header('referrer-policy', 'no-referrer');
|
|
101
|
+
reply.header('cache-control', 'no-store, private');
|
|
102
|
+
reply.header('x-robots-tag', 'noindex, nofollow');
|
|
103
|
+
|
|
104
|
+
const link = await verifyPreviewToken((request.query || {}).token, fastify.jwt);
|
|
105
|
+
if (!link) {
|
|
106
|
+
reply.status(404);
|
|
107
|
+
return reply.type('text/html').send(await render404('/_preview'));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const page = await getPage(link.urlPath);
|
|
111
|
+
if (!page) {
|
|
112
|
+
reply.status(404);
|
|
113
|
+
return reply.type('text/html').send(await render404(link.urlPath));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A disabled project stays a hard kill switch. A share link narrows who
|
|
117
|
+
// may see one page; it is not a way around taking a whole project down.
|
|
118
|
+
const project = await getProjectForPage(page.urlPath || link.urlPath, page.project);
|
|
119
|
+
if (!(await isProjectEnabled(project))) {
|
|
120
|
+
reply.status(404);
|
|
121
|
+
return reply.type('text/html').send(await render404(link.urlPath));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Rendered as anonymous on purpose: the recipient has no account, so
|
|
125
|
+
// menus and role-gated shortcodes must show them the least, not the
|
|
126
|
+
// most. The token satisfies this page's own gate and nothing else.
|
|
127
|
+
await touchPreviewLink(link.id);
|
|
128
|
+
const html = await renderPage(page, {
|
|
129
|
+
baseUrl: getBaseUrl(request),
|
|
130
|
+
user: null,
|
|
131
|
+
preview: {
|
|
132
|
+
status: page.status || 'draft',
|
|
133
|
+
urlPath: page.urlPath || link.urlPath,
|
|
134
|
+
shared: {expiresAt: link.expiresAt, label: link.label}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return reply.type('text/html').send(html);
|
|
138
|
+
});
|
|
139
|
+
|
|
84
140
|
// Public pages catch-all
|
|
85
141
|
fastify.get('/*', async (request, reply) => {
|
|
86
142
|
const rawPath = request.params['*'];
|
|
@@ -76,6 +76,33 @@ export async function getPage(urlPath, opts = {}) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Compose a page object from unsaved frontmatter and body, without touching
|
|
81
|
+
* disk.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately runs the same `parseMarkdown` call as readPageFile() and
|
|
84
|
+
* returns the same shape, so the editor's full-page preview renders through
|
|
85
|
+
* the identical pipeline the public site would use on save. Anything that
|
|
86
|
+
* diverges here becomes a preview that lies.
|
|
87
|
+
*
|
|
88
|
+
* `urlPath` matters even for an unsaved page: `[menu location="…"]` and menu
|
|
89
|
+
* bindings resolve against it, so a preview of a page destined for
|
|
90
|
+
* /projects/x sees the menus that page will actually get.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} urlPath
|
|
93
|
+
* @param {object} frontmatter
|
|
94
|
+
* @param {string} body
|
|
95
|
+
* @param {object} [opts]
|
|
96
|
+
* @param {object|null} [opts.user]
|
|
97
|
+
* @returns {Promise<object>} Same shape as getPage()
|
|
98
|
+
*/
|
|
99
|
+
export async function buildPreviewPage(urlPath, frontmatter, body, opts = {}) {
|
|
100
|
+
const raw = serialiseMarkdown({...(frontmatter || {})}, body || '');
|
|
101
|
+
const {data, content, html, usedComponents, tags} =
|
|
102
|
+
await parseMarkdown(raw, {user: opts.user || null, urlPath});
|
|
103
|
+
return {...data, urlPath, content, html, usedComponents, cacheTags: tags};
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
/**
|
|
80
107
|
* Create a new page. Auto-creates parent directories.
|
|
81
108
|
*
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview Links
|
|
3
|
+
*
|
|
4
|
+
* Revocable, expiring, single-page share links for unpublished content. They
|
|
5
|
+
* exist for the reviewer who has no account: a client, an external designer,
|
|
6
|
+
* a copywriter. The holder of the link sees exactly one page and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* Why a server-side index rather than a bare signed token: a JWT alone cannot
|
|
9
|
+
* be withdrawn. Once a link is out it is out until it expires, and "I sent that
|
|
10
|
+
* to the wrong address" is precisely the moment you need it gone. Every token
|
|
11
|
+
* carries a `jti` that must still be present and unrevoked in the index, so
|
|
12
|
+
* revocation is immediate.
|
|
13
|
+
*
|
|
14
|
+
* The index lives at content/preview-links.json and is small by construction -
|
|
15
|
+
* expired records are pruned whenever it is written.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'fs/promises';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import {config} from '../config.js';
|
|
21
|
+
|
|
22
|
+
const STORE_PATH = path.join(config.content.contentDir, 'preview-links.json');
|
|
23
|
+
|
|
24
|
+
/** Token type claim - a page-preview token must never satisfy an API guard. */
|
|
25
|
+
export const PREVIEW_TOKEN_TYPE = 'page-preview';
|
|
26
|
+
|
|
27
|
+
/** Expiry choices offered to the issuer, in seconds. */
|
|
28
|
+
export const EXPIRY_CHOICES = {
|
|
29
|
+
'1h': 3600,
|
|
30
|
+
'24h': 86_400,
|
|
31
|
+
'7d': 604_800,
|
|
32
|
+
'30d': 2_592_000
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const DEFAULT_EXPIRY = EXPIRY_CHOICES['7d'];
|
|
36
|
+
const MAX_EXPIRY = EXPIRY_CHOICES['30d'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the link index. Missing or corrupt file yields an empty index rather
|
|
40
|
+
* than throwing - a broken store must not take the public site down, it must
|
|
41
|
+
* only mean no link validates.
|
|
42
|
+
*
|
|
43
|
+
* @returns {Promise<object[]>}
|
|
44
|
+
*/
|
|
45
|
+
async function readStore() {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.readFile(STORE_PATH, 'utf8');
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write the index, dropping records that expired more than a day ago.
|
|
57
|
+
*
|
|
58
|
+
* @param {object[]} links
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async function writeStore(links) {
|
|
62
|
+
const cutoff = Date.now() - 86_400_000;
|
|
63
|
+
const kept = links.filter(l => new Date(l.expiresAt).getTime() > cutoff);
|
|
64
|
+
await fs.mkdir(path.dirname(STORE_PATH), {recursive: true});
|
|
65
|
+
await fs.writeFile(STORE_PATH, JSON.stringify(kept, null, 2), 'utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalise a requested lifetime to a whole number of seconds within bounds.
|
|
70
|
+
*
|
|
71
|
+
* @param {string|number} [requested] - A key of EXPIRY_CHOICES or seconds
|
|
72
|
+
* @returns {number}
|
|
73
|
+
*/
|
|
74
|
+
export function resolveExpirySeconds(requested) {
|
|
75
|
+
if (typeof requested === 'string' && requested in EXPIRY_CHOICES) {
|
|
76
|
+
return EXPIRY_CHOICES[requested];
|
|
77
|
+
}
|
|
78
|
+
const n = Number(requested);
|
|
79
|
+
if (Number.isFinite(n) && n > 0) return Math.min(Math.floor(n), MAX_EXPIRY);
|
|
80
|
+
return DEFAULT_EXPIRY;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Mint a share link for one page.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} params
|
|
87
|
+
* @param {string} params.urlPath - Page the link unlocks; nothing else
|
|
88
|
+
* @param {object} params.jwt - fastify.jwt
|
|
89
|
+
* @param {string} [params.label] - Free-text note for the issuer's own use
|
|
90
|
+
* @param {string} [params.createdBy] - Issuing user's id
|
|
91
|
+
* @param {string} [params.createdByName] - Issuing user's display name
|
|
92
|
+
* @param {string|number} [params.expiresIn]
|
|
93
|
+
* @returns {Promise<{id: string, token: string, urlPath: string, expiresAt: string, label: string}>}
|
|
94
|
+
*/
|
|
95
|
+
export async function createPreviewLink({urlPath, jwt, label = '', createdBy = null, createdByName = '', expiresIn}) {
|
|
96
|
+
if (!urlPath || typeof urlPath !== 'string' || !urlPath.startsWith('/')) {
|
|
97
|
+
throw new Error('A urlPath beginning with / is required');
|
|
98
|
+
}
|
|
99
|
+
const seconds = resolveExpirySeconds(expiresIn);
|
|
100
|
+
const id = crypto.randomUUID();
|
|
101
|
+
const createdAt = new Date().toISOString();
|
|
102
|
+
const expiresAt = new Date(Date.now() + seconds * 1000).toISOString();
|
|
103
|
+
|
|
104
|
+
// The page is named in the token, so a holder cannot repoint it, and the
|
|
105
|
+
// index is consulted on every use, so it can be withdrawn.
|
|
106
|
+
const token = jwt.sign({type: PREVIEW_TOKEN_TYPE, jti: id, urlPath}, {expiresIn: seconds});
|
|
107
|
+
|
|
108
|
+
const links = await readStore();
|
|
109
|
+
links.push({
|
|
110
|
+
id, urlPath, label: String(label || '').slice(0, 200),
|
|
111
|
+
createdBy, createdByName: String(createdByName || '').slice(0, 120),
|
|
112
|
+
createdAt, expiresAt, revokedAt: null, lastUsedAt: null, useCount: 0
|
|
113
|
+
});
|
|
114
|
+
await writeStore(links);
|
|
115
|
+
|
|
116
|
+
return {id, token, urlPath, expiresAt, label};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* List links, newest first. Optionally filtered to one page.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} [urlPath]
|
|
123
|
+
* @returns {Promise<object[]>}
|
|
124
|
+
*/
|
|
125
|
+
export async function listPreviewLinks(urlPath) {
|
|
126
|
+
const links = await readStore();
|
|
127
|
+
const filtered = urlPath ? links.filter(l => l.urlPath === urlPath) : links;
|
|
128
|
+
return filtered
|
|
129
|
+
.map(l => ({...l, expired: new Date(l.expiresAt).getTime() <= Date.now()}))
|
|
130
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Revoke a link. Idempotent; returns false when the id is unknown.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} id
|
|
137
|
+
* @returns {Promise<boolean>}
|
|
138
|
+
*/
|
|
139
|
+
export async function revokePreviewLink(id) {
|
|
140
|
+
const links = await readStore();
|
|
141
|
+
const link = links.find(l => l.id === id);
|
|
142
|
+
if (!link) return false;
|
|
143
|
+
link.revokedAt = link.revokedAt || new Date().toISOString();
|
|
144
|
+
await writeStore(links);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Verify a share token and return the page it unlocks.
|
|
150
|
+
*
|
|
151
|
+
* Every check is a separate reason to refuse: a bad signature, the wrong token
|
|
152
|
+
* type (an access token must not double as a share link, nor the reverse), an
|
|
153
|
+
* expired `exp`, an unknown `jti`, a revoked record, an index expiry that has
|
|
154
|
+
* passed, or a token whose `urlPath` no longer matches the record it names.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} token
|
|
157
|
+
* @param {object} jwt - fastify.jwt
|
|
158
|
+
* @returns {Promise<{urlPath: string, id: string, expiresAt: string, label: string}|null>}
|
|
159
|
+
*/
|
|
160
|
+
export async function verifyPreviewToken(token, jwt) {
|
|
161
|
+
if (!token || typeof token !== 'string') return null;
|
|
162
|
+
|
|
163
|
+
let payload;
|
|
164
|
+
try {
|
|
165
|
+
payload = jwt.verify(token);
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
if (payload?.type !== PREVIEW_TOKEN_TYPE || !payload.jti || !payload.urlPath) return null;
|
|
170
|
+
|
|
171
|
+
const links = await readStore();
|
|
172
|
+
const link = links.find(l => l.id === payload.jti);
|
|
173
|
+
if (!link) return null;
|
|
174
|
+
if (link.revokedAt) return null;
|
|
175
|
+
if (link.urlPath !== payload.urlPath) return null;
|
|
176
|
+
if (new Date(link.expiresAt).getTime() <= Date.now()) return null;
|
|
177
|
+
|
|
178
|
+
return {urlPath: link.urlPath, id: link.id, expiresAt: link.expiresAt, label: link.label || ''};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Record a use of a link. Best-effort - a failure to write the counter must
|
|
183
|
+
* never deny an otherwise valid preview.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} id
|
|
186
|
+
* @returns {Promise<void>}
|
|
187
|
+
*/
|
|
188
|
+
export async function touchPreviewLink(id) {
|
|
189
|
+
try {
|
|
190
|
+
const links = await readStore();
|
|
191
|
+
const link = links.find(l => l.id === id);
|
|
192
|
+
if (!link) return;
|
|
193
|
+
link.lastUsedAt = new Date().toISOString();
|
|
194
|
+
link.useCount = (link.useCount || 0) + 1;
|
|
195
|
+
await writeStore(links);
|
|
196
|
+
} catch { /* counters are not load-bearing */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Drop every link for a page - used when the page is deleted or renamed, so a
|
|
201
|
+
* link cannot outlive the thing it pointed at.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} urlPath
|
|
204
|
+
* @returns {Promise<number>} how many were removed
|
|
205
|
+
*/
|
|
206
|
+
export async function deletePreviewLinksForPage(urlPath) {
|
|
207
|
+
const links = await readStore();
|
|
208
|
+
const remaining = links.filter(l => l.urlPath !== urlPath);
|
|
209
|
+
const removed = links.length - remaining.length;
|
|
210
|
+
if (removed) await writeStore(remaining);
|
|
211
|
+
return removed;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Absolute path of the backing store - exported for tests. */
|
|
215
|
+
export const storePath = STORE_PATH;
|
|
@@ -449,46 +449,43 @@ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage})
|
|
|
449
449
|
* never with the session cookie - keeping every state-changing call
|
|
450
450
|
* explicitly credentialled and the cookie free of write authority.
|
|
451
451
|
*
|
|
452
|
-
*
|
|
452
|
+
* A share-link view (`preview.shared`) gets a different bar: its reader has no
|
|
453
|
+
* account, so Edit and Publish would only be doors they cannot open. They get
|
|
454
|
+
* the expiry instead, which is the thing they actually need to know.
|
|
455
|
+
*
|
|
456
|
+
* @param {{status?: string, urlPath?: string, user?: object|null,
|
|
457
|
+
* shared?: {expiresAt?: string, label?: string}}} preview
|
|
453
458
|
* @returns {string}
|
|
454
459
|
*/
|
|
455
460
|
function buildDraftBanner(preview) {
|
|
456
461
|
const urlPath = preview.urlPath || '/';
|
|
457
462
|
const status = preview.status || 'draft';
|
|
458
463
|
const user = preview.user || {};
|
|
464
|
+
const shared = preview.shared || null;
|
|
459
465
|
const who = escapeHtml(user.name || user.email || 'an authorised user');
|
|
460
466
|
const role = escapeHtml(user.role || '');
|
|
461
467
|
const editHref = '/admin/#/pages/edit' + escapeHtml(urlPath);
|
|
462
468
|
const pathAttr = escapeHtml(urlPath);
|
|
463
|
-
const label = escapeHtml(status.charAt(0).toUpperCase() + status.slice(1));
|
|
469
|
+
const label = escapeHtml(shared ? 'Preview' : status.charAt(0).toUpperCase() + status.slice(1));
|
|
464
470
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
</style>
|
|
484
|
-
<span class="dmdb-pill">${label}</span>
|
|
485
|
-
<span class="dmdb-text">Not published - the public gets a 404 here. You can see it because you are signed in as <strong>${who}</strong>${role ? ` (${role})` : ''}.</span>
|
|
486
|
-
<span class="dmdb-actions">
|
|
487
|
-
<a class="dmdb-btn" href="${editHref}">Edit page</a>
|
|
488
|
-
<button type="button" class="dmdb-publish" data-dmdb-publish>Publish</button>
|
|
489
|
-
<button type="button" class="dmdb-close" data-dmdb-close aria-label="Hide draft banner" title="Hide">×</button>
|
|
490
|
-
</span>
|
|
491
|
-
<script>
|
|
471
|
+
const message = shared
|
|
472
|
+
? `Shared preview of an unpublished page.${formatExpiry(shared.expiresAt)} It is not visible to the public.`
|
|
473
|
+
: `Not published - the public gets a 404 here. You can see it because you are signed in as <strong>${who}</strong>${role ? ` (${role})` : ''}.`;
|
|
474
|
+
|
|
475
|
+
const actions = shared
|
|
476
|
+
? ''
|
|
477
|
+
: `<a class="dmdb-btn" href="${editHref}">Edit page</a>
|
|
478
|
+
<button type="button" class="dmdb-publish" data-dmdb-publish>Publish</button>`;
|
|
479
|
+
|
|
480
|
+
const script = shared
|
|
481
|
+
? `<script>
|
|
482
|
+
(function(){
|
|
483
|
+
var bar = document.getElementById('dm-draft-banner');
|
|
484
|
+
if (!bar) return;
|
|
485
|
+
bar.querySelector('[data-dmdb-close]').addEventListener('click', function(){ bar.hidden = true; });
|
|
486
|
+
})();
|
|
487
|
+
</script>`
|
|
488
|
+
: `<script>
|
|
492
489
|
(function(){
|
|
493
490
|
var bar = document.getElementById('dm-draft-banner');
|
|
494
491
|
if (!bar) return;
|
|
@@ -501,7 +498,7 @@ border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#
|
|
|
501
498
|
if (!token) { window.location.href = '/admin/'; return; }
|
|
502
499
|
if (!window.confirm('Publish this page? It becomes visible to everyone.')) return;
|
|
503
500
|
btn.disabled = true;
|
|
504
|
-
btn.textContent = 'Publishing
|
|
501
|
+
btn.textContent = 'Publishing\u2026';
|
|
505
502
|
fetch('/api/pages' + path, {
|
|
506
503
|
method: 'PUT',
|
|
507
504
|
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token},
|
|
@@ -516,10 +513,51 @@ border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#
|
|
|
516
513
|
});
|
|
517
514
|
});
|
|
518
515
|
})();
|
|
519
|
-
</script
|
|
516
|
+
</script>`;
|
|
517
|
+
|
|
518
|
+
return `<div id="dm-draft-banner" role="status" aria-live="polite" data-url-path="${pathAttr}">
|
|
519
|
+
<style>
|
|
520
|
+
#dm-draft-banner{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;
|
|
521
|
+
padding:.6rem .9rem;font:500 .8125rem/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;color:#1c1601;
|
|
522
|
+
background:repeating-linear-gradient(135deg,#ffcf33,#ffcf33 14px,#f5b800 14px,#f5b800 28px);box-shadow:0 -2px 12px rgba(0,0,0,.28)}
|
|
523
|
+
#dm-draft-banner[hidden]{display:none}
|
|
524
|
+
#dm-draft-banner .dmdb-pill{flex:none;padding:.2rem .55rem;border-radius:999px;background:#1c1601;color:#ffcf33;
|
|
525
|
+
font-weight:700;font-size:.6875rem;letter-spacing:.08em;text-transform:uppercase}
|
|
526
|
+
#dm-draft-banner .dmdb-text{flex:1 1 16rem;min-width:0}
|
|
527
|
+
#dm-draft-banner .dmdb-actions{flex:none;display:flex;align-items:center;gap:.4rem}
|
|
528
|
+
#dm-draft-banner button,#dm-draft-banner a.dmdb-btn{font:inherit;cursor:pointer;border:1px solid rgba(28,22,1,.45);
|
|
529
|
+
border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#1c1601;text-decoration:none}
|
|
530
|
+
#dm-draft-banner button:hover,#dm-draft-banner a.dmdb-btn:hover{background:#fff}
|
|
531
|
+
#dm-draft-banner button[disabled]{opacity:.55;cursor:default}
|
|
532
|
+
#dm-draft-banner .dmdb-publish{background:#1c1601;color:#ffcf33;border-color:#1c1601}
|
|
533
|
+
#dm-draft-banner .dmdb-publish:hover{background:#000;color:#ffe27a}
|
|
534
|
+
#dm-draft-banner .dmdb-close{padding:.2rem .5rem;line-height:1;font-size:1rem;background:transparent;border-color:transparent}
|
|
535
|
+
@media print{#dm-draft-banner{display:none}}
|
|
536
|
+
</style>
|
|
537
|
+
<span class="dmdb-pill">${label}</span>
|
|
538
|
+
<span class="dmdb-text">${message}</span>
|
|
539
|
+
<span class="dmdb-actions">
|
|
540
|
+
${actions}
|
|
541
|
+
<button type="button" class="dmdb-close" data-dmdb-close aria-label="Hide preview banner" title="Hide">×</button>
|
|
542
|
+
</span>
|
|
543
|
+
${script}
|
|
520
544
|
</div>`;
|
|
521
545
|
}
|
|
522
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Render a share link's expiry as a sentence fragment, or '' when unknown.
|
|
549
|
+
*
|
|
550
|
+
* @param {string} [iso]
|
|
551
|
+
* @returns {string}
|
|
552
|
+
*/
|
|
553
|
+
function formatExpiry(iso) {
|
|
554
|
+
if (!iso) return '';
|
|
555
|
+
const when = new Date(iso);
|
|
556
|
+
if (Number.isNaN(when.getTime())) return '';
|
|
557
|
+
const opts = {day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'};
|
|
558
|
+
return ` This link expires ${escapeHtml(when.toLocaleString('en-GB', opts))}.`;
|
|
559
|
+
}
|
|
560
|
+
|
|
523
561
|
function escapeHtml(str) {
|
|
524
562
|
return String(str)
|
|
525
563
|
.replace(/&/g, '&')
|