@velastack/cms 0.2.6 → 0.3.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/dist/backend/factory.js +2 -0
- package/dist/backend/index.d.ts +3 -1
- package/dist/backend/index.js +1 -0
- package/dist/backend/routes/context.d.ts +3 -1
- package/dist/backend/routes/deploy.d.ts +9 -0
- package/dist/backend/routes/deploy.js +46 -0
- package/dist/backend/routes/table.js +3 -0
- package/dist/backend/types.d.ts +30 -0
- package/dist/components/admin-bar/admin-bar-internal.svelte +95 -1
- package/dist/components/admin-bar/deploy-dialog.svelte +76 -0
- package/dist/components/admin-bar/deploy-dialog.svelte.d.ts +15 -0
- package/dist/components/admin-bar/deploy-state.d.ts +7 -0
- package/dist/components/admin-bar/deploy-state.js +13 -0
- package/dist/components/admin-bar/format-relative.d.ts +6 -0
- package/dist/components/admin-bar/format-relative.js +37 -0
- package/dist/components/admin-bar/publish-dialog.svelte +1 -30
- package/dist/core/wire.d.ts +17 -0
- package/package.json +1 -1
package/dist/backend/factory.js
CHANGED
|
@@ -37,6 +37,7 @@ export const createCmsBackend = (options = {}) => {
|
|
|
37
37
|
const storage = createStorage(uploadDir);
|
|
38
38
|
const editors = createEditorStore(db);
|
|
39
39
|
const auth = options.auth ?? localEditors();
|
|
40
|
+
const deploy = options.deploy;
|
|
40
41
|
const restParam = options.restParam ?? 'path';
|
|
41
42
|
const cookieName = options.cookie?.name ?? 'cms_session';
|
|
42
43
|
const resolveProject = options.resolveProject ?? (() => 'default');
|
|
@@ -167,6 +168,7 @@ export const createCmsBackend = (options = {}) => {
|
|
|
167
168
|
respond,
|
|
168
169
|
storage,
|
|
169
170
|
auth,
|
|
171
|
+
deploy,
|
|
170
172
|
authCtx,
|
|
171
173
|
mediaUrl: mediaUrlFor(event),
|
|
172
174
|
mountPath,
|
package/dist/backend/index.d.ts
CHANGED
|
@@ -31,12 +31,14 @@
|
|
|
31
31
|
* show up in `vela dev`.
|
|
32
32
|
*/
|
|
33
33
|
export { createCmsBackend, type CmsBackend } from './factory.js';
|
|
34
|
+
export { CmsDeployError } from './routes/deploy.js';
|
|
34
35
|
export { localEditors, type LocalEditorsOptions } from './auth/local-editors.js';
|
|
35
36
|
export { createEditorStore, type CmsEditorStore, type CreateEditorInput, type CmsSessionToken } from './auth/editors.js';
|
|
36
37
|
export { hashPassword, verifyPassword, type ScryptParams } from './auth/scrypt.js';
|
|
37
38
|
export { createCmsTestClient, type CmsTestClient, type TestResponse } from './testing/harness.js';
|
|
38
39
|
export { createTestFixture, TEST_SCRYPT, type TestFixture, type TestFixtureOptions } from './testing/fixtures.js';
|
|
39
|
-
export type { CmsAuthAdapter, CmsAuthContext, CmsBackendOptions, CmsCookieOptions, CmsCorsOptions, CmsEditor, CmsSessionGrant } from './types.js';
|
|
40
|
+
export type { CmsAuthAdapter, CmsAuthContext, CmsBackendOptions, CmsCookieOptions, CmsCorsOptions, CmsDeployAdapter, CmsDeployContext, CmsDeployRun, CmsDeployState, CmsEditor, CmsSessionGrant } from './types.js';
|
|
41
|
+
export type { CmsStore } from './store/queries.js';
|
|
40
42
|
export type { SqliteDb, SqliteStatement } from './store/sqlite.js';
|
|
41
43
|
export type { Migration } from './store/migrations/index.js';
|
|
42
44
|
export { MIGRATIONS } from './store/migrations/index.js';
|
package/dist/backend/index.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* show up in `vela dev`.
|
|
32
32
|
*/
|
|
33
33
|
export { createCmsBackend } from './factory.js';
|
|
34
|
+
export { CmsDeployError } from './routes/deploy.js';
|
|
34
35
|
export { localEditors } from './auth/local-editors.js';
|
|
35
36
|
export { createEditorStore } from './auth/editors.js';
|
|
36
37
|
export { hashPassword, verifyPassword } from './auth/scrypt.js';
|
|
@@ -11,7 +11,7 @@ import type { CmsStore } from '../store/queries.js';
|
|
|
11
11
|
import type { ReadCache } from '../store/cache.js';
|
|
12
12
|
import type { CmsResponders } from '../http.js';
|
|
13
13
|
import type { CmsStorage } from '../storage.js';
|
|
14
|
-
import type { CmsAuthAdapter, CmsAuthContext, CmsEditor } from '../types.js';
|
|
14
|
+
import type { CmsAuthAdapter, CmsAuthContext, CmsDeployAdapter, CmsEditor } from '../types.js';
|
|
15
15
|
export type RouteCtx = {
|
|
16
16
|
event: RequestEvent;
|
|
17
17
|
/** The tenant this request is for. `'default'` in a single-tenant mount. */
|
|
@@ -25,6 +25,8 @@ export type RouteCtx = {
|
|
|
25
25
|
respond: CmsResponders;
|
|
26
26
|
storage: CmsStorage;
|
|
27
27
|
auth: CmsAuthAdapter;
|
|
28
|
+
/** The host's deploy adapter, when configured. */
|
|
29
|
+
deploy?: CmsDeployAdapter;
|
|
28
30
|
authCtx: CmsAuthContext;
|
|
29
31
|
/** Absolute-or-root-relative URL for an uploaded file, per the host's
|
|
30
32
|
* configured media base. */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RouteCtx } from './context.js';
|
|
2
|
+
/** Thrown by an adapter to map a failure to a status: 409 while a run is in
|
|
3
|
+
* flight, 503 when the project has no pipeline to deploy through. */
|
|
4
|
+
export declare class CmsDeployError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
constructor(status: number, message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare const getDeploy: (ctx: RouteCtx) => Promise<Response>;
|
|
9
|
+
export declare const postDeploy: (ctx: RouteCtx) => Promise<Response>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { requireUser } from './context.js';
|
|
2
|
+
/** Thrown by an adapter to map a failure to a status: 409 while a run is in
|
|
3
|
+
* flight, 503 when the project has no pipeline to deploy through. */
|
|
4
|
+
export class CmsDeployError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = 'CmsDeployError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const deployContext = (ctx) => ({
|
|
13
|
+
event: ctx.event,
|
|
14
|
+
projectId: ctx.projectId,
|
|
15
|
+
user: requireUser(ctx),
|
|
16
|
+
store: ctx.store
|
|
17
|
+
});
|
|
18
|
+
/** Adapter errors carry their own status; anything else is a bare 500 so a
|
|
19
|
+
* host bug never reaches the bar as an HTML error page it would try to parse. */
|
|
20
|
+
const respond = async (run, status) => {
|
|
21
|
+
try {
|
|
22
|
+
return Response.json(await run(), { status });
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (err instanceof CmsDeployError) {
|
|
26
|
+
return new Response(err.message, {
|
|
27
|
+
status: err.status,
|
|
28
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return new Response(null, { status: 500 });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
export const getDeploy = (ctx) => {
|
|
35
|
+
const { deploy } = ctx;
|
|
36
|
+
if (!deploy) {
|
|
37
|
+
return Promise.resolve(Response.json({ available: false }));
|
|
38
|
+
}
|
|
39
|
+
return respond(() => deploy.status(deployContext(ctx)), 200);
|
|
40
|
+
};
|
|
41
|
+
export const postDeploy = (ctx) => {
|
|
42
|
+
const { deploy } = ctx;
|
|
43
|
+
if (!deploy)
|
|
44
|
+
return Promise.resolve(new Response(null, { status: 404 }));
|
|
45
|
+
return respond(() => deploy.trigger(deployContext(ctx)), 202);
|
|
46
|
+
};
|
|
@@ -5,6 +5,7 @@ import { getUser, postLogout } from './user.js';
|
|
|
5
5
|
import { getLogin, getLoginSuccess, postLogin } from './login.js';
|
|
6
6
|
import { deleteReleaseItems, getRelease, getReleaseHistory, postReleaseDiscard, postReleaseHistoryPreviewKey, postReleaseItems, postReleasePreviewKey, postReleasePublish, postReleaseRevert } from './release.js';
|
|
7
7
|
import { deleteMediaItem, getMedia, getUpload, postMedia } from './media.js';
|
|
8
|
+
import { getDeploy, postDeploy } from './deploy.js';
|
|
8
9
|
import { postTestReset } from './test-reset.js';
|
|
9
10
|
export const ROUTES = [
|
|
10
11
|
{ method: 'GET', segments: ['docs'], auth: 'public', handler: getDocs },
|
|
@@ -62,6 +63,8 @@ export const ROUTES = [
|
|
|
62
63
|
auth: 'required',
|
|
63
64
|
handler: postReleaseHistoryPreviewKey
|
|
64
65
|
},
|
|
66
|
+
{ method: 'GET', segments: ['deploy'], auth: 'required', handler: getDeploy },
|
|
67
|
+
{ method: 'POST', segments: ['deploy'], auth: 'required', handler: postDeploy },
|
|
65
68
|
{ method: 'GET', segments: ['media'], auth: 'required', handler: getMedia },
|
|
66
69
|
{ method: 'POST', segments: ['media'], auth: 'required', handler: postMedia },
|
|
67
70
|
{ method: 'DELETE', segments: ['media', ':id'], auth: 'required', handler: deleteMediaItem },
|
package/dist/backend/types.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
import type { CmsDeployState } from '../core/wire.js';
|
|
3
|
+
import type { CmsStore } from './store/queries.js';
|
|
2
4
|
import type { SqliteDb } from './store/sqlite.js';
|
|
5
|
+
export type { CmsDeployRun, CmsDeployState } from '../core/wire.js';
|
|
3
6
|
/** An authenticated content editor. */
|
|
4
7
|
export type CmsEditor = {
|
|
5
8
|
id: string;
|
|
@@ -41,6 +44,29 @@ export type CmsAuthAdapter = {
|
|
|
41
44
|
* regardless. */
|
|
42
45
|
logout?(event: RequestEvent, ctx: CmsAuthContext): Promise<void>;
|
|
43
46
|
};
|
|
47
|
+
/** Handed to every deploy adapter method. `store` is the CMS store, so the
|
|
48
|
+
* host can read which release is current (`store.getReleaseHistory`). */
|
|
49
|
+
export type CmsDeployContext = {
|
|
50
|
+
event: RequestEvent;
|
|
51
|
+
projectId: string;
|
|
52
|
+
user: CmsEditor;
|
|
53
|
+
store: CmsStore;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The seam between the CMS and whatever rebuilds the site.
|
|
57
|
+
*
|
|
58
|
+
* A static site bakes published content in at build time, so a publish only
|
|
59
|
+
* reaches visitors once the site is built again. The host owns that pipeline;
|
|
60
|
+
* the CMS only exposes it: `status()` backs the admin bar's menu and dialog,
|
|
61
|
+
* `trigger()` starts a run. Throw `CmsDeployError(409, …)` while a run is in
|
|
62
|
+
* flight and `CmsDeployError(503, …)` when the project has nothing to deploy to.
|
|
63
|
+
*/
|
|
64
|
+
export type CmsDeployAdapter = {
|
|
65
|
+
/** Can this project be deployed from the bar, and what happened last. */
|
|
66
|
+
status(ctx: CmsDeployContext): Promise<CmsDeployState>;
|
|
67
|
+
/** Start a deploy; resolves with the new state. */
|
|
68
|
+
trigger(ctx: CmsDeployContext): Promise<CmsDeployState>;
|
|
69
|
+
};
|
|
44
70
|
export type CmsCookieOptions = {
|
|
45
71
|
name?: string;
|
|
46
72
|
path?: string;
|
|
@@ -85,6 +111,10 @@ export type CmsBackendOptions = {
|
|
|
85
111
|
restParam?: string;
|
|
86
112
|
/** Identity and authorization. Defaults to `localEditors()`. */
|
|
87
113
|
auth?: CmsAuthAdapter;
|
|
114
|
+
/** Rebuilds and deploys the site from the admin bar. Omit it — the default —
|
|
115
|
+
* and the bar never shows the action, which is right for a same-origin
|
|
116
|
+
* mount where the site reads the CMS live. */
|
|
117
|
+
deploy?: CmsDeployAdapter;
|
|
88
118
|
cookie?: CmsCookieOptions;
|
|
89
119
|
/**
|
|
90
120
|
* Cross-origin access. `false` (the default) emits no CORS headers, which is
|
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
import type { CmsPayload, CmsScopeEntry } from '../cms/scope.js';
|
|
14
14
|
import CssRoot from './css-root.svelte';
|
|
15
15
|
import { adminBarTheme, type AdminBarTheme } from './theme.svelte.js';
|
|
16
|
+
import type { CmsDeployState } from '../../core/wire.js';
|
|
16
17
|
import DeletePageDialog, {
|
|
17
18
|
type DeletePageMode,
|
|
18
19
|
type RedirectTarget
|
|
19
20
|
} from './delete-page-dialog.svelte';
|
|
21
|
+
import DeployDialog from './deploy-dialog.svelte';
|
|
22
|
+
import { isDeploying } from './deploy-state.js';
|
|
20
23
|
import HistoryPanel from './history-panel.svelte';
|
|
21
24
|
import KeyboardShortcutsDialog from './keyboard-shortcuts-dialog.svelte';
|
|
22
25
|
import MediaPanel from './media-panel.svelte';
|
|
@@ -83,6 +86,7 @@
|
|
|
83
86
|
let shareLinkOpen = $state(false);
|
|
84
87
|
let shortcutsOpen = $state(false);
|
|
85
88
|
let siteSettingsOpen = $state(false);
|
|
89
|
+
let deployOpen = $state(false);
|
|
86
90
|
const anyPanelOpen = $derived(
|
|
87
91
|
seoOpen ||
|
|
88
92
|
historyOpen ||
|
|
@@ -93,7 +97,8 @@
|
|
|
93
97
|
chooserOpen ||
|
|
94
98
|
shareLinkOpen ||
|
|
95
99
|
shortcutsOpen ||
|
|
96
|
-
siteSettingsOpen
|
|
100
|
+
siteSettingsOpen ||
|
|
101
|
+
deployOpen
|
|
97
102
|
);
|
|
98
103
|
let publishing = $state(false);
|
|
99
104
|
let publishError = $state<string | null>(null);
|
|
@@ -145,6 +150,7 @@
|
|
|
145
150
|
shareLinkOpen = false;
|
|
146
151
|
shortcutsOpen = false;
|
|
147
152
|
siteSettingsOpen = false;
|
|
153
|
+
deployOpen = false;
|
|
148
154
|
newDialog = null;
|
|
149
155
|
newPageError = null;
|
|
150
156
|
duplicateSource = null;
|
|
@@ -625,6 +631,71 @@
|
|
|
625
631
|
}
|
|
626
632
|
};
|
|
627
633
|
|
|
634
|
+
// Live-site deploy. A static site only shows visitors what was published
|
|
635
|
+
// when it was last built; the host's deploy adapter rebuilds it. The
|
|
636
|
+
// backend answers `available: false` unless the host configured one, so a
|
|
637
|
+
// same-origin mount never shows the item; an older backend 404s and the
|
|
638
|
+
// state simply stays null.
|
|
639
|
+
const DEPLOY_POLL_MS = 5000;
|
|
640
|
+
const DEPLOY_POLL_LIMIT = 120; // ~10 minutes, then leave it to the next mount
|
|
641
|
+
let deployState = $state<CmsDeployState | null>(null);
|
|
642
|
+
let deploying = $state(false);
|
|
643
|
+
let deployError = $state<string | null>(null);
|
|
644
|
+
const deployAvailable = $derived(deployState?.available === true);
|
|
645
|
+
const deployInFlight = $derived(isDeploying(deployState));
|
|
646
|
+
|
|
647
|
+
const fetchDeployState = async () => {
|
|
648
|
+
try {
|
|
649
|
+
const res = await fetch(`${endpoint}/deploy`, { credentials: 'include' });
|
|
650
|
+
if (!res.ok) return;
|
|
651
|
+
deployState = (await res.json()) as CmsDeployState;
|
|
652
|
+
} catch {
|
|
653
|
+
// Network failure: keep whatever we last knew.
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
$effect(() => {
|
|
658
|
+
void fetchDeployState();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// Poll while a run is in flight, dialog open or not; the interval is
|
|
662
|
+
// cleared when the run finishes or the bar unmounts.
|
|
663
|
+
$effect(() => {
|
|
664
|
+
if (!deployInFlight) return;
|
|
665
|
+
let ticks = 0;
|
|
666
|
+
const id = setInterval(() => {
|
|
667
|
+
if (++ticks > DEPLOY_POLL_LIMIT) {
|
|
668
|
+
clearInterval(id);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
void fetchDeployState();
|
|
672
|
+
}, DEPLOY_POLL_MS);
|
|
673
|
+
return () => clearInterval(id);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const onOpenDeploy = () => {
|
|
677
|
+
if (!deployAvailable || deployInFlight) return;
|
|
678
|
+
closeAllPanels();
|
|
679
|
+
deployError = null;
|
|
680
|
+
deployOpen = true;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const onConfirmDeploy = async () => {
|
|
684
|
+
deploying = true;
|
|
685
|
+
deployError = null;
|
|
686
|
+
try {
|
|
687
|
+
const res = await fetch(`${endpoint}/deploy`, { method: 'POST', credentials: 'include' });
|
|
688
|
+
if (!res.ok) {
|
|
689
|
+
deployError = (await res.text()) || 'Could not start the deploy.';
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
deployState = (await res.json()) as CmsDeployState;
|
|
693
|
+
deployOpen = false;
|
|
694
|
+
} finally {
|
|
695
|
+
deploying = false;
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
628
699
|
const refreshAfterReleaseChange = async () => {
|
|
629
700
|
const key = cmsStore.openRelease?.preview_key ?? null;
|
|
630
701
|
await setPreviewParam(key, { replace: true });
|
|
@@ -1350,6 +1421,17 @@
|
|
|
1350
1421
|
Discard All Changes…
|
|
1351
1422
|
</Menubar.Item>
|
|
1352
1423
|
|
|
1424
|
+
{#if deployAvailable}
|
|
1425
|
+
<Menubar.Label class={menuLabelClass}>Live site</Menubar.Label>
|
|
1426
|
+
<Menubar.Item
|
|
1427
|
+
class={menuItemClass}
|
|
1428
|
+
onSelect={onOpenDeploy}
|
|
1429
|
+
disabled={deployInFlight}
|
|
1430
|
+
>
|
|
1431
|
+
{deployInFlight ? 'Deploying…' : 'Deploy Site…'}
|
|
1432
|
+
</Menubar.Item>
|
|
1433
|
+
{/if}
|
|
1434
|
+
|
|
1353
1435
|
<Menubar.Label class={menuLabelClass}>History</Menubar.Label>
|
|
1354
1436
|
<Menubar.Item class={menuItemClass} onSelect={onOpenHistory}>
|
|
1355
1437
|
Recent Releases
|
|
@@ -1688,6 +1770,18 @@
|
|
|
1688
1770
|
onRegenerate={onRegeneratePreviewKey}
|
|
1689
1771
|
/>
|
|
1690
1772
|
|
|
1773
|
+
{#if deployState?.available}
|
|
1774
|
+
<DeployDialog
|
|
1775
|
+
open={deployOpen}
|
|
1776
|
+
onOpenChange={(next) => (deployOpen = next)}
|
|
1777
|
+
deploy={deployState}
|
|
1778
|
+
unpublishedCount={counts.total}
|
|
1779
|
+
{deploying}
|
|
1780
|
+
error={deployError}
|
|
1781
|
+
onConfirm={onConfirmDeploy}
|
|
1782
|
+
/>
|
|
1783
|
+
{/if}
|
|
1784
|
+
|
|
1691
1785
|
<KeyboardShortcutsDialog open={shortcutsOpen} onOpenChange={(next) => (shortcutsOpen = next)} />
|
|
1692
1786
|
</div>
|
|
1693
1787
|
</CssRoot>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CmsDeployState } from '../../core/wire.js';
|
|
3
|
+
import { describeLatestRun, isDeploying } from './deploy-state.js';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import * as Dialog from './ui/dialog/index.js';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
open: boolean;
|
|
9
|
+
onOpenChange: (open: boolean) => void;
|
|
10
|
+
deploy: CmsDeployState;
|
|
11
|
+
/** Items in the working copy; they are not part of a deploy. */
|
|
12
|
+
unpublishedCount: number;
|
|
13
|
+
/** The POST is in flight. */
|
|
14
|
+
deploying: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
onConfirm: () => void;
|
|
17
|
+
};
|
|
18
|
+
let { open, onOpenChange, deploy, unpublishedCount, deploying, error, onConfirm }: Props =
|
|
19
|
+
$props();
|
|
20
|
+
|
|
21
|
+
const inFlight = $derived(isDeploying(deploy));
|
|
22
|
+
const status = $derived(describeLatestRun(deploy.latest));
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<Dialog.Root
|
|
26
|
+
{open}
|
|
27
|
+
onOpenChange={(next) => {
|
|
28
|
+
if (!next && deploying) return;
|
|
29
|
+
onOpenChange(next);
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
<Dialog.Content class="vela:max-w-[28rem]">
|
|
33
|
+
<Dialog.Header>
|
|
34
|
+
<Dialog.Title>Deploy site</Dialog.Title>
|
|
35
|
+
<Dialog.Description>
|
|
36
|
+
Rebuilds the site with the latest published content and puts it live at
|
|
37
|
+
<span class="vela:font-mono vela:text-bar-text">{deploy.site?.url ?? 'the live site'}</span
|
|
38
|
+
>. Visitors only see published changes after a deploy.
|
|
39
|
+
</Dialog.Description>
|
|
40
|
+
</Dialog.Header>
|
|
41
|
+
|
|
42
|
+
<div class="vela:px-6 vela:pb-4 vela:flex vela:flex-col vela:gap-3">
|
|
43
|
+
<p class="vela:m-0 vela:text-[12px] vela:text-bar-text-secondary">{status}</p>
|
|
44
|
+
|
|
45
|
+
{#if unpublishedCount > 0}
|
|
46
|
+
<p
|
|
47
|
+
class="vela:m-0 vela:px-3 vela:py-2 vela:rounded-md vela:text-[12px]
|
|
48
|
+
vela:bg-[var(--cms-status-warn-bg)] vela:text-[var(--cms-status-warn-text)]"
|
|
49
|
+
>
|
|
50
|
+
{unpublishedCount}
|
|
51
|
+
{unpublishedCount === 1 ? 'unpublished change' : 'unpublished changes'} in your working copy
|
|
52
|
+
will not be included. Publish first to put them live.
|
|
53
|
+
</p>
|
|
54
|
+
{/if}
|
|
55
|
+
|
|
56
|
+
{#if error}
|
|
57
|
+
<p
|
|
58
|
+
role="alert"
|
|
59
|
+
class="vela:m-0 vela:px-3 vela:py-2 vela:rounded-md vela:text-[12px]
|
|
60
|
+
vela:bg-[var(--cms-status-error-bg)] vela:text-[var(--cms-status-error-text)]"
|
|
61
|
+
>
|
|
62
|
+
{error}
|
|
63
|
+
</p>
|
|
64
|
+
{/if}
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<Dialog.Footer class="vela:justify-end vela:pt-2">
|
|
68
|
+
<Button variant="ghost" onclick={() => onOpenChange(false)} disabled={deploying}
|
|
69
|
+
>Cancel</Button
|
|
70
|
+
>
|
|
71
|
+
<Button variant="default" onclick={onConfirm} disabled={deploying || inFlight}>
|
|
72
|
+
{deploying ? 'Deploying…' : 'Deploy'}
|
|
73
|
+
</Button>
|
|
74
|
+
</Dialog.Footer>
|
|
75
|
+
</Dialog.Content>
|
|
76
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CmsDeployState } from '../../core/wire.js';
|
|
2
|
+
type Props = {
|
|
3
|
+
open: boolean;
|
|
4
|
+
onOpenChange: (open: boolean) => void;
|
|
5
|
+
deploy: CmsDeployState;
|
|
6
|
+
/** Items in the working copy; they are not part of a deploy. */
|
|
7
|
+
unpublishedCount: number;
|
|
8
|
+
/** The POST is in flight. */
|
|
9
|
+
deploying: boolean;
|
|
10
|
+
error: string | null;
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
};
|
|
13
|
+
declare const DeployDialog: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type DeployDialog = ReturnType<typeof DeployDialog>;
|
|
15
|
+
export default DeployDialog;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the bar shows for a site deploy, kept out of the component so it can be
|
|
3
|
+
* unit tested: whether a run is in flight, and the one-line status of the last.
|
|
4
|
+
*/
|
|
5
|
+
import type { CmsDeployRun, CmsDeployState } from '../../core/wire.js';
|
|
6
|
+
export declare const isDeploying: (state: CmsDeployState | null) => boolean;
|
|
7
|
+
export declare const describeLatestRun: (latest: CmsDeployRun | null | undefined, now?: number) => string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { formatRelative } from './format-relative.js';
|
|
2
|
+
export const isDeploying = (state) => state?.latest?.status === 'pending' || state?.latest?.status === 'building';
|
|
3
|
+
export const describeLatestRun = (latest, now = Date.now()) => {
|
|
4
|
+
if (!latest)
|
|
5
|
+
return 'Never deployed';
|
|
6
|
+
if (latest.status === 'pending' || latest.status === 'building')
|
|
7
|
+
return 'Deploying…';
|
|
8
|
+
const when = formatRelative(latest.finishedAt ?? latest.createdAt, now);
|
|
9
|
+
if (latest.status === 'failed') {
|
|
10
|
+
return latest.error ? `Deploy failed ${when}: ${latest.error}` : `Deploy failed ${when}`;
|
|
11
|
+
}
|
|
12
|
+
return `Deployed ${when}`;
|
|
13
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A timestamp as the bar speaks about it: "just now", "5 minutes ago", "this
|
|
3
|
+
* afternoon", "yesterday", then a short date. Shared by the publish and
|
|
4
|
+
* deploy dialogs; `now` is a parameter so tests can pin it.
|
|
5
|
+
*/
|
|
6
|
+
export declare const formatRelative: (iso: string, now?: number) => string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A timestamp as the bar speaks about it: "just now", "5 minutes ago", "this
|
|
3
|
+
* afternoon", "yesterday", then a short date. Shared by the publish and
|
|
4
|
+
* deploy dialogs; `now` is a parameter so tests can pin it.
|
|
5
|
+
*/
|
|
6
|
+
export const formatRelative = (iso, now = Date.now()) => {
|
|
7
|
+
const t = new Date(iso).getTime();
|
|
8
|
+
if (Number.isNaN(t))
|
|
9
|
+
return '';
|
|
10
|
+
const seconds = Math.max(0, Math.floor((now - t) / 1000));
|
|
11
|
+
if (seconds < 45)
|
|
12
|
+
return 'just now';
|
|
13
|
+
const minutes = Math.floor(seconds / 60);
|
|
14
|
+
if (minutes < 60)
|
|
15
|
+
return minutes <= 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
|
16
|
+
const d = new Date(t);
|
|
17
|
+
const today = new Date(now);
|
|
18
|
+
const sameDay = d.getFullYear() === today.getFullYear() &&
|
|
19
|
+
d.getMonth() === today.getMonth() &&
|
|
20
|
+
d.getDate() === today.getDate();
|
|
21
|
+
if (sameDay) {
|
|
22
|
+
const h = d.getHours();
|
|
23
|
+
if (h < 12)
|
|
24
|
+
return 'this morning';
|
|
25
|
+
if (h < 17)
|
|
26
|
+
return 'this afternoon';
|
|
27
|
+
return 'this evening';
|
|
28
|
+
}
|
|
29
|
+
const yesterday = new Date(today);
|
|
30
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
31
|
+
const isYesterday = d.getFullYear() === yesterday.getFullYear() &&
|
|
32
|
+
d.getMonth() === yesterday.getMonth() &&
|
|
33
|
+
d.getDate() === yesterday.getDate();
|
|
34
|
+
if (isYesterday)
|
|
35
|
+
return 'yesterday';
|
|
36
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
37
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { ReleaseItem } from '../cms/cms-store.svelte.js';
|
|
4
4
|
import type { CmsPayload } from '../cms/scope.js';
|
|
5
5
|
import { diffPaths, type Tree } from '../../core/path.js';
|
|
6
|
+
import { formatRelative } from './format-relative.js';
|
|
6
7
|
import { resolveRouteOnlyParams, resolveRouteUrl } from './resolve-route.js';
|
|
7
8
|
import { Badge, type BadgeVariant } from './ui/badge/index.js';
|
|
8
9
|
import { Button } from './ui/button/index.js';
|
|
@@ -229,36 +230,6 @@
|
|
|
229
230
|
return summarizeNames(meta, 'Updated');
|
|
230
231
|
};
|
|
231
232
|
|
|
232
|
-
const formatRelative = (iso: string): string => {
|
|
233
|
-
const t = new Date(iso).getTime();
|
|
234
|
-
if (Number.isNaN(t)) return '';
|
|
235
|
-
const now = Date.now();
|
|
236
|
-
const seconds = Math.max(0, Math.floor((now - t) / 1000));
|
|
237
|
-
if (seconds < 45) return 'just now';
|
|
238
|
-
const minutes = Math.floor(seconds / 60);
|
|
239
|
-
if (minutes < 60) return minutes <= 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
|
240
|
-
const d = new Date(t);
|
|
241
|
-
const today = new Date(now);
|
|
242
|
-
const sameDay =
|
|
243
|
-
d.getFullYear() === today.getFullYear() &&
|
|
244
|
-
d.getMonth() === today.getMonth() &&
|
|
245
|
-
d.getDate() === today.getDate();
|
|
246
|
-
if (sameDay) {
|
|
247
|
-
const h = d.getHours();
|
|
248
|
-
if (h < 12) return 'this morning';
|
|
249
|
-
if (h < 17) return 'this afternoon';
|
|
250
|
-
return 'this evening';
|
|
251
|
-
}
|
|
252
|
-
const yesterday = new Date(today);
|
|
253
|
-
yesterday.setDate(yesterday.getDate() - 1);
|
|
254
|
-
const isYesterday =
|
|
255
|
-
d.getFullYear() === yesterday.getFullYear() &&
|
|
256
|
-
d.getMonth() === yesterday.getMonth() &&
|
|
257
|
-
d.getDate() === yesterday.getDate();
|
|
258
|
-
if (isYesterday) return 'yesterday';
|
|
259
|
-
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
260
|
-
};
|
|
261
|
-
|
|
262
233
|
/** Submit-disabled gate: nothing to publish when there are no items at all.
|
|
263
234
|
* (Group count is fine here too — no items → no groups.) */
|
|
264
235
|
const total = $derived(items.length);
|
package/dist/core/wire.d.ts
CHANGED
|
@@ -155,6 +155,23 @@ export type ListMediaResult = {
|
|
|
155
155
|
items: MediaItem[];
|
|
156
156
|
total: number;
|
|
157
157
|
};
|
|
158
|
+
/** One build-and-deploy of the site, as the host's deploy adapter reports it. */
|
|
159
|
+
export type CmsDeployRun = {
|
|
160
|
+
id: string;
|
|
161
|
+
status: 'pending' | 'building' | 'deployed' | 'failed';
|
|
162
|
+
createdAt: string;
|
|
163
|
+
finishedAt?: string;
|
|
164
|
+
error?: string;
|
|
165
|
+
};
|
|
166
|
+
/** `GET /deploy` and `POST /deploy`. `available` is `false` when the backend
|
|
167
|
+
* has no deploy adapter, which is how a same-origin mount hides the action. */
|
|
168
|
+
export type CmsDeployState = {
|
|
169
|
+
available: boolean;
|
|
170
|
+
site?: {
|
|
171
|
+
url: string;
|
|
172
|
+
};
|
|
173
|
+
latest?: CmsDeployRun | null;
|
|
174
|
+
};
|
|
158
175
|
export type DocsResponse = {
|
|
159
176
|
contents: Tree;
|
|
160
177
|
} | PageDeleteOutcome;
|