@cardinali-studio/adminkit 2.2.24 → 2.2.33
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/LICENSE +12 -0
- package/README.md +213 -0
- package/dist/app/app.css +3 -3
- package/dist/app/delivery.d.ts +11 -0
- package/dist/app/delivery.js +46 -0
- package/dist/app/index.d.ts +4 -1
- package/dist/app/index.js +3 -2
- package/dist/app/lib/deliveries.d.ts +8 -0
- package/dist/app/lib/delivery-network.d.ts +25 -0
- package/dist/app/lib/delivery-network.js +159 -0
- package/dist/app/lib/delivery-upload.d.ts +29 -11
- package/dist/app/lib/delivery-upload.js +408 -131
- package/dist/app/lib/upload-queue.d.ts +21 -3
- package/dist/app/lib/upload-queue.js +76 -19
- package/dist/app/routes/Deliveries.js +108 -29
- package/dist/app/routes/UploadDock.js +6 -2
- package/dist/direct-publication.d.ts +20 -0
- package/dist/direct-publication.js +75 -0
- package/dist/functions/_shared/b2.js +22 -3
- package/dist/functions/_shared/delivery-crypto.js +5 -0
- package/dist/functions/_shared/delivery-limits.d.ts +9 -0
- package/dist/functions/_shared/delivery-limits.js +29 -0
- package/dist/functions/_shared/delivery-manifest-v2.sql +49 -0
- package/dist/functions/_shared/delivery-manifest.js +392 -0
- package/dist/functions/_shared/delivery-purge.js +17 -1
- package/dist/functions/api/deliveries.js +177 -49
- package/dist/functions/api/delivery/image.js +11 -6
- package/dist/functions/api/delivery/multipart.js +196 -64
- package/dist/functions/delivery-public/assets/client.js +246 -0
- package/dist/functions/delivery-public/assets/delivery.css +297 -0
- package/dist/functions/delivery-public/assets/gallery.js +243 -0
- package/dist/functions/delivery-public/assets/stage.js +107 -0
- package/dist/functions/delivery-public/index.js +637 -0
- package/dist/functions/delivery-public/migrations/0002_deliveries.sql +128 -0
- package/dist/functions/delivery-public/migrations/0003_delivery_quick_hero.sql +34 -0
- package/dist/functions/delivery-public/migrations/0004_delivery_file_expiry.sql +9 -0
- package/dist/functions/delivery-public/migrations/0006_delivery_manifest_v2.sql +49 -0
- package/dist/functions/delivery-public/migrations/0007_delivery_link_aliases.sql +8 -0
- package/dist/functions/delivery-public/migrations/0008_delivery_web_format.sql +2 -0
- package/dist/functions/delivery-public/migrations/0009_delivery_download_labels.sql +2 -0
- package/dist/functions/index.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +11 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (C) 2026 Emanuele Cardinali
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted.
|
|
5
|
+
|
|
6
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
7
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
8
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
9
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
10
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
11
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
12
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# AdminKit
|
|
2
|
+
|
|
3
|
+
AdminKit is the administration engine we use for small, content-driven websites built with Hugo and deployed on Cloudflare Pages.
|
|
4
|
+
|
|
5
|
+
It turns a Zod schema into three things that must agree:
|
|
6
|
+
|
|
7
|
+
- the model edited in the browser;
|
|
8
|
+
- the YAML front matter stored in the repository;
|
|
9
|
+
- the validation performed by the server.
|
|
10
|
+
|
|
11
|
+
There is no second list of fields hidden in the UI, and no second serializer hidden in a Worker. A field is declared once. The rest of the system reads that declaration.
|
|
12
|
+
|
|
13
|
+
The package also contains the React administration shell, a media library for R2, Cloudflare Pages Functions, and the persistence primitives used by the D1 publication path.
|
|
14
|
+
|
|
15
|
+
## The important property
|
|
16
|
+
|
|
17
|
+
An admin panel should not damage a file merely because it does not understand all of it.
|
|
18
|
+
|
|
19
|
+
AdminKit preserves the Markdown body, unknown front-matter keys, and their comments. An omitted managed field means “leave it alone”, not “delete it”. Before overwriting an existing file, `checkFidelity()` can prove that reading and writing it again is byte-for-byte stable.
|
|
20
|
+
|
|
21
|
+
If the codec cannot reproduce a file, it says so. Refusing a save is better than producing plausible-looking YAML with missing data.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install @cardinali-studio/adminkit zod
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The core codec needs Zod 4. The React application and form exports also use the optional peer dependencies listed in `package.json`; install the ones required by the entry points you use.
|
|
30
|
+
|
|
31
|
+
The package is ESM-only.
|
|
32
|
+
|
|
33
|
+
## A complete codec example
|
|
34
|
+
|
|
35
|
+
Each field has two pieces of metadata:
|
|
36
|
+
|
|
37
|
+
- `ui` describes how a human edits it;
|
|
38
|
+
- `fm` describes how it lives in the file.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import * as z from "zod";
|
|
42
|
+
import {
|
|
43
|
+
checkFidelity,
|
|
44
|
+
deserializeItem,
|
|
45
|
+
fm,
|
|
46
|
+
serializeItem,
|
|
47
|
+
ui,
|
|
48
|
+
yamlStr,
|
|
49
|
+
} from "@cardinali-studio/adminkit";
|
|
50
|
+
|
|
51
|
+
const Article = {
|
|
52
|
+
title: z.string().min(1).max(120)
|
|
53
|
+
.register(ui, {
|
|
54
|
+
label: "Title",
|
|
55
|
+
widget: "text",
|
|
56
|
+
group: "content",
|
|
57
|
+
})
|
|
58
|
+
.register(fm, {
|
|
59
|
+
keys: ["title"],
|
|
60
|
+
encode: (value) => [`title: ${yamlStr(value)}`],
|
|
61
|
+
decode: (raw) => String(raw.title ?? ""),
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
published: z.boolean()
|
|
65
|
+
.register(ui, {
|
|
66
|
+
label: "Published",
|
|
67
|
+
widget: "boolean",
|
|
68
|
+
group: "visibility",
|
|
69
|
+
})
|
|
70
|
+
.register(fm, {
|
|
71
|
+
keys: ["visible"],
|
|
72
|
+
encode: (value) => [`visible: ${value === true}`],
|
|
73
|
+
decode: (raw) => raw.visible === true,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const markdown = `---
|
|
78
|
+
# Kept with the field.
|
|
79
|
+
title: "A small page"
|
|
80
|
+
visible: false
|
|
81
|
+
|
|
82
|
+
# AdminKit does not own this key, so it stays untouched.
|
|
83
|
+
build-note: "keep me"
|
|
84
|
+
---
|
|
85
|
+
The Markdown body stays untouched too.
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
if (!checkFidelity(markdown, Article).ok) {
|
|
89
|
+
throw new Error("This file cannot be edited without loss");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const article = deserializeItem(markdown, Article);
|
|
93
|
+
|
|
94
|
+
const updated = serializeItem(
|
|
95
|
+
{ ...article, published: true },
|
|
96
|
+
{ shape: Article, oldMd: markdown },
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Notice that the model calls the boolean `published` while the file calls it `visible`. That conversion belongs to the schema. Hugo templates, React components, and API handlers should not each invent their own version of it.
|
|
101
|
+
|
|
102
|
+
### Deleting and retiring fields
|
|
103
|
+
|
|
104
|
+
Absence is not deletion. To clear a value, pass the empty value understood by its encoder and return no YAML lines.
|
|
105
|
+
|
|
106
|
+
When a key is obsolete, `retired(schema, keys)` keeps ownership of the old key while emitting nothing. Existing files then lose that key on their next intentional save, without maintaining a separate migration list.
|
|
107
|
+
|
|
108
|
+
## Mounting the admin application
|
|
109
|
+
|
|
110
|
+
The application is deliberately mounted as one unit:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { mountAdmin, type SiteAdminConfig } from "@cardinali-studio/adminkit/app";
|
|
114
|
+
import "@cardinali-studio/adminkit/theme.css";
|
|
115
|
+
import "./site-theme.css";
|
|
116
|
+
|
|
117
|
+
const site: SiteAdminConfig = {
|
|
118
|
+
// Site identity, page types, schemas, enabled modules and persistence mode.
|
|
119
|
+
// TypeScript checks the complete contract here.
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
mountAdmin({
|
|
123
|
+
site,
|
|
124
|
+
root: document.querySelector("#root"),
|
|
125
|
+
storagePrefix: "example-admin",
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Import the site's theme after AdminKit's theme. This lets a site replace the CSS custom properties without increasing selector specificity.
|
|
130
|
+
|
|
131
|
+
`storagePrefix` is part of the site's browser data. Changing it on a live installation silently resets saved media preferences, so migrations should keep the old value.
|
|
132
|
+
|
|
133
|
+
The shell currently speaks Italian. Content can be monolingual or multilingual; the language of the administration interface is a separate concern.
|
|
134
|
+
|
|
135
|
+
## Cloudflare Pages Functions
|
|
136
|
+
|
|
137
|
+
Cloudflare routes Functions by file, while an npm package cannot create files in a consuming project's `functions/` directory. A site therefore keeps two small adapters.
|
|
138
|
+
|
|
139
|
+
The API catch-all:
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
// functions/admin/api/[[path]].js
|
|
143
|
+
import { handleAdminApi } from "@cardinali-studio/adminkit/functions";
|
|
144
|
+
import { serverConfig } from "../../../src/admin/site-config.js";
|
|
145
|
+
|
|
146
|
+
export function onRequest(context) {
|
|
147
|
+
return handleAdminApi(context, serverConfig);
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
And the access middleware:
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
// functions/admin/_middleware.js
|
|
155
|
+
export { adminAccess as onRequest } from "@cardinali-studio/adminkit/functions";
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Site-specific endpoints may live beside these files. More specific Cloudflare routes take precedence over the catch-all.
|
|
159
|
+
|
|
160
|
+
The server configuration is site-owned. It supplies schemas, repository details, enabled modules, storage bindings and, in D1 mode, the complete document validator. Missing optional modules fail closed.
|
|
161
|
+
|
|
162
|
+
## Persistence and publication
|
|
163
|
+
|
|
164
|
+
The original persistence path writes Markdown through GitHub and lets Hugo build it. The newer path stores immutable revisions in D1 and publishes an exact release snapshot.
|
|
165
|
+
|
|
166
|
+
The D1 modules are intentionally lower-level than the admin shell. They provide:
|
|
167
|
+
|
|
168
|
+
- deterministic request checksums and idempotent saves;
|
|
169
|
+
- compare-and-swap protection for concurrent drafts;
|
|
170
|
+
- immutable, checksum-covered release exports;
|
|
171
|
+
- a single deployment lane for code and content;
|
|
172
|
+
- copy-first media moves and delete journals for R2;
|
|
173
|
+
- versioned site-access secrets;
|
|
174
|
+
- pure release materialization, with filesystem effects left to CI.
|
|
175
|
+
|
|
176
|
+
They do not decide what a site's document means. Validation and materialization remain site-owned.
|
|
177
|
+
|
|
178
|
+
## Entry points
|
|
179
|
+
|
|
180
|
+
| Import | Purpose |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `@cardinali-studio/adminkit` | Schema registries, front-matter codec, API client, i18n, persistence and release primitives |
|
|
183
|
+
| `@cardinali-studio/adminkit/app` | Complete React administration shell |
|
|
184
|
+
| `@cardinali-studio/adminkit/form` | Schema form and media components |
|
|
185
|
+
| `@cardinali-studio/adminkit/functions` | Cloudflare Pages API router and access middleware |
|
|
186
|
+
| `@cardinali-studio/adminkit/materializer` | Pure release-to-files materialization |
|
|
187
|
+
| `@cardinali-studio/adminkit/preview` | Local preview component |
|
|
188
|
+
| `@cardinali-studio/adminkit/testing` | Content-stability checks for consuming sites |
|
|
189
|
+
| `@cardinali-studio/adminkit/theme.css` | Default design tokens |
|
|
190
|
+
|
|
191
|
+
## What this is not
|
|
192
|
+
|
|
193
|
+
AdminKit is not a generic headless CMS, a form builder, or a promise to support every YAML construct.
|
|
194
|
+
|
|
195
|
+
It is opinionated about the useful narrow case: Hugo content, React 19, Zod 4, Cloudflare Pages, R2 and optionally D1. The front-matter emitter supports the structures used by that system and throws on values it cannot represent safely.
|
|
196
|
+
|
|
197
|
+
This narrowness is intentional. A smaller system with explicit failure modes is easier to trust around content that people have edited by hand.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
npm test
|
|
203
|
+
npm run build
|
|
204
|
+
npm run check
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`npm run check` rebuilds the package and runs the Node test suite. The tests include byte-identical codec round trips, malformed-input rejection, import resolution, concurrency conflicts, release invariants, media-operation transitions, and a guard against site-specific code entering the shared package.
|
|
208
|
+
|
|
209
|
+
Generated files live in `dist/` and are the only application files included in the npm tarball, in addition to npm's standard package metadata and this README.
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
[0BSD](./LICENSE). Use it, copy it, change it, ship it. Attribution is appreciated but not required.
|
package/dist/app/app.css
CHANGED
|
@@ -1216,14 +1216,14 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
|
|
|
1216
1216
|
border-bottom: 1px solid var(--ak-line);
|
|
1217
1217
|
}
|
|
1218
1218
|
.dl-cover-head p { margin: 3px 0 0; color: var(--ak-ink-quiet); font-size: 11px; }
|
|
1219
|
-
.dl-cover-actions { display: flex; gap: 6px; flex: none; }
|
|
1220
|
-
.dl-cover-grid { padding: 14px; display: grid; grid-template-columns: repeat(auto-fill,minmax(
|
|
1219
|
+
.dl-cover-actions { display: flex; flex-wrap: wrap; gap: 6px; flex: none; }
|
|
1220
|
+
.dl-cover-grid { padding: 14px; display: grid; grid-template-columns: repeat(auto-fill,minmax(min(150px,100%),1fr)); align-items: start; gap: 10px; }
|
|
1221
1221
|
.dl-cover-grid button {
|
|
1222
1222
|
position: relative; min-width: 0; padding: 0; overflow: hidden; cursor: pointer;
|
|
1223
1223
|
border: 2px solid transparent; border-radius: 6px; background: var(--ak-bg);
|
|
1224
1224
|
}
|
|
1225
1225
|
.dl-cover-grid button.is-chosen { border-color: var(--ak-accent); }
|
|
1226
|
-
.dl-cover-grid img { width: 100%;
|
|
1226
|
+
.dl-cover-grid img { width: 100%; height: auto; display: block; object-fit: contain; }
|
|
1227
1227
|
.dl-cover-grid em {
|
|
1228
1228
|
position: absolute; top: 5px; right: 5px; width: 18px; height: 18px; display: grid; place-items: center;
|
|
1229
1229
|
border-radius: 50%; background: var(--ak-accent); color: #fff; font: 600 11px/1 var(--ak-font); font-style: normal;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import "../theme.css";
|
|
2
|
+
import "./app.css";
|
|
3
|
+
import "./next.css";
|
|
4
|
+
export interface DeliveryAdminOptions {
|
|
5
|
+
name: string;
|
|
6
|
+
storagePrefix: string;
|
|
7
|
+
root?: HTMLElement | null;
|
|
8
|
+
websiteUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Delivery-only entry: no editorial, GitHub, booking or R2 hooks are mounted. */
|
|
11
|
+
export declare function mountDeliveryAdmin(options: DeliveryAdminOptions): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StrictMode, useEffect, useState } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { setStoragePrefix } from "../index.js";
|
|
5
|
+
import { applyTheme, initialTheme, useTheme } from "./lib/theme.js";
|
|
6
|
+
import { useHashRoute } from "./lib/router.js";
|
|
7
|
+
import { useDeliveries } from "./lib/deliveries.js";
|
|
8
|
+
import { useUploadCompletions } from "./lib/upload-queue.js";
|
|
9
|
+
import { DeliveriesList, DeliveriesTotal, DeliveryWorkspace, NewDeliveryButton } from "./routes/Deliveries.js";
|
|
10
|
+
import { UploadDock } from "./routes/UploadDock.js";
|
|
11
|
+
import "../theme.css";
|
|
12
|
+
import "./app.css";
|
|
13
|
+
import "./next.css";
|
|
14
|
+
/** Delivery-only entry: no editorial, GitHub, booking or R2 hooks are mounted. */
|
|
15
|
+
export function mountDeliveryAdmin(options) {
|
|
16
|
+
setStoragePrefix(options.storagePrefix);
|
|
17
|
+
applyTheme(initialTheme());
|
|
18
|
+
const root = options.root ?? document.getElementById("root");
|
|
19
|
+
if (!root)
|
|
20
|
+
throw new Error("adminkit: elemento di montaggio non trovato");
|
|
21
|
+
createRoot(root).render(_jsx(StrictMode, { children: _jsx(DeliveryAdmin, { ...options }) }));
|
|
22
|
+
}
|
|
23
|
+
function DeliveryAdmin({ name, websiteUrl }) {
|
|
24
|
+
const [route, go] = useHashRoute();
|
|
25
|
+
const id = route[0] === "deliveries" ? route[1] : undefined;
|
|
26
|
+
const api = useDeliveries(id);
|
|
27
|
+
const { theme, toggle } = useTheme();
|
|
28
|
+
const completions = useUploadCompletions();
|
|
29
|
+
const [draftKey, setDraftKey] = useState(0);
|
|
30
|
+
const [selectedId, setSelectedId] = useState();
|
|
31
|
+
useEffect(() => { if (completions > 0)
|
|
32
|
+
void api.load(); }, [completions, api.load]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const prevent = (e) => e.preventDefault();
|
|
35
|
+
window.addEventListener("dragover", prevent);
|
|
36
|
+
window.addEventListener("drop", prevent);
|
|
37
|
+
return () => { window.removeEventListener("dragover", prevent); window.removeEventListener("drop", prevent); };
|
|
38
|
+
}, []);
|
|
39
|
+
const open = (value) => { setSelectedId(undefined); go(`/deliveries/${value}`); };
|
|
40
|
+
// Never render the previous transfer while the requested detail is loading.
|
|
41
|
+
const detail = id && api.detail?.id === id ? api.detail : null;
|
|
42
|
+
return _jsxs("div", { className: "app", children: [_jsxs("nav", { className: "rail", "aria-label": "Navigazione", children: [_jsxs("div", { className: "rail-brand", children: [_jsx("span", { className: "rail-brand-name", children: name }), _jsx("span", { className: "rail-brand-role", children: "Consegne" })] }), _jsx("button", { className: "rail-item is-on", onClick: () => go("/deliveries"), children: "Transfer" }), _jsxs("div", { className: "rail-foot", children: [websiteUrl && _jsx("a", { className: "rail-item", href: websiteUrl, target: "_blank", rel: "noreferrer", children: "Sito dello studio" }), _jsx("button", { className: "rail-item", onClick: toggle, children: theme === "dark" ? "Tema chiaro" : "Tema scuro" })] })] }), _jsxs("aside", { className: "col", "aria-label": "Elenco transfer", children: [_jsxs("div", { className: "col-head col-head-actions", children: [_jsx("span", { className: "col-title", children: "Transfer" }), _jsx(NewDeliveryButton, { onCreate: () => { setSelectedId(undefined); setDraftKey(v => v + 1); go("/deliveries"); } })] }), _jsx("div", { className: "col-body", children: !api.rows ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" }) : api.rows.length === 0 ? _jsx("p", { className: "ak-hint", children: "Nessun transfer, per ora." })
|
|
43
|
+
: _jsx(DeliveriesList, { rows: api.rows, current: id || selectedId, onOpen: open }) }), _jsx("div", { className: "dl-col-foot", children: api.rows && _jsx(DeliveriesTotal, { rows: api.rows }) })] }), _jsx("main", { className: "pane", children: _jsx("div", { className: "pane-body", children: api.error ? _jsxs("div", { role: "alert", children: [_jsx("p", { className: "ak-error", children: api.error }), _jsx("button", { className: "ak-btn", onClick: () => void api.refresh(), children: "Riprova" })] })
|
|
44
|
+
: id && !detail ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
|
|
45
|
+
: _jsx(DeliveryWorkspace, { delivery: detail, busy: api.busy, onAction: api.action, onRefresh: api.refresh, onReload: api.load, onRead: api.read, onDeleted: () => go("/deliveries"), onOpen: open, onSelected: setSelectedId }, id || `new-${draftKey}`) }) }), _jsx(UploadDock, {})] });
|
|
46
|
+
}
|
package/dist/app/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ComponentType } from "react";
|
|
1
2
|
import { type SiteAdminConfig } from "./site.js";
|
|
2
3
|
export type { SiteAdminConfig } from "./site.js";
|
|
3
4
|
export interface MountOptions {
|
|
@@ -5,6 +6,8 @@ export interface MountOptions {
|
|
|
5
6
|
site: SiteAdminConfig;
|
|
6
7
|
/** L'elemento in cui montare. Default: `#root`. */
|
|
7
8
|
root?: HTMLElement | null;
|
|
9
|
+
/** Site-owned editorial workspace, sharing the kit bootstrap and media library. */
|
|
10
|
+
workspace?: ComponentType;
|
|
8
11
|
/**
|
|
9
12
|
* Namespace delle preferenze salvate nel browser (ordinamento dei media, ultima cartella
|
|
10
13
|
* aperta nel selettore).
|
|
@@ -15,4 +18,4 @@ export interface MountOptions {
|
|
|
15
18
|
*/
|
|
16
19
|
storagePrefix: string;
|
|
17
20
|
}
|
|
18
|
-
export declare function mountAdmin({ site, root, storagePrefix }: MountOptions): void;
|
|
21
|
+
export declare function mountAdmin({ site, root, storagePrefix, workspace }: MountOptions): void;
|
package/dist/app/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { setStoragePrefix } from "../index.js";
|
|
|
18
18
|
import { App } from "./App.js";
|
|
19
19
|
import { applyTheme, initialTheme } from "./lib/theme.js";
|
|
20
20
|
import { setSiteConfig } from "./site.js";
|
|
21
|
-
export function mountAdmin({ site, root, storagePrefix }) {
|
|
21
|
+
export function mountAdmin({ site, root, storagePrefix, workspace }) {
|
|
22
22
|
setSiteConfig(site);
|
|
23
23
|
setStoragePrefix(storagePrefix);
|
|
24
24
|
/* SINCRONO, prima del render: se il tema lo applicasse solo l'effetto dentro `App`, ci sarebbe
|
|
@@ -29,5 +29,6 @@ export function mountAdmin({ site, root, storagePrefix }) {
|
|
|
29
29
|
const el = root ?? document.getElementById("root");
|
|
30
30
|
if (!el)
|
|
31
31
|
throw new Error("adminkit: elemento di montaggio non trovato (atteso #root)");
|
|
32
|
-
|
|
32
|
+
const Workspace = workspace ?? App;
|
|
33
|
+
createRoot(el).render(_jsx(StrictMode, { children: _jsx(Workspace, {}) }));
|
|
33
34
|
}
|
|
@@ -7,6 +7,8 @@ export interface DeliverySummary {
|
|
|
7
7
|
licenseNotes: string;
|
|
8
8
|
ctaEnabled: boolean;
|
|
9
9
|
ctaLabel: string;
|
|
10
|
+
downloadOriginalsLabel?: string;
|
|
11
|
+
downloadWebLabel?: string;
|
|
10
12
|
ctaUrl: string;
|
|
11
13
|
expiresAt: string | null;
|
|
12
14
|
filesExpiresAt: string | null;
|
|
@@ -21,12 +23,15 @@ export interface DeliverySummary {
|
|
|
21
23
|
downloadCount: number;
|
|
22
24
|
}
|
|
23
25
|
export interface DeliveryPackage {
|
|
26
|
+
manifestVersion?: 1 | 2;
|
|
27
|
+
nextCursor?: number | null;
|
|
24
28
|
id: string;
|
|
25
29
|
deliveryId: string;
|
|
26
30
|
label: string;
|
|
27
31
|
position: number;
|
|
28
32
|
galleryEnabled: boolean;
|
|
29
33
|
webEnabled: boolean;
|
|
34
|
+
webFormat?: "webp" | "jpg";
|
|
30
35
|
state: "uploading" | "ready" | "failed";
|
|
31
36
|
sourceFingerprint: string;
|
|
32
37
|
fileCount: number;
|
|
@@ -37,6 +42,8 @@ export interface DeliveryPackage {
|
|
|
37
42
|
images: DeliveryPackageImage[];
|
|
38
43
|
}
|
|
39
44
|
export interface DeliveryPackageImage {
|
|
45
|
+
width?: number;
|
|
46
|
+
height?: number;
|
|
40
47
|
index: number;
|
|
41
48
|
name: string;
|
|
42
49
|
previewUrl: string;
|
|
@@ -72,6 +79,7 @@ export interface DeliveryLink {
|
|
|
72
79
|
lastDownloadAt: string | null;
|
|
73
80
|
}
|
|
74
81
|
export interface Delivery extends DeliverySummary {
|
|
82
|
+
canShortenLinks?: boolean;
|
|
75
83
|
packages: DeliveryPackage[];
|
|
76
84
|
links: DeliveryLink[];
|
|
77
85
|
heroes: DeliveryHero[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Richieste recuperabili delle consegne. Usare retry solo per operazioni idempotenti. */
|
|
2
|
+
export declare class UploadCancelled extends Error {
|
|
3
|
+
constructor();
|
|
4
|
+
}
|
|
5
|
+
export interface UploadControl {
|
|
6
|
+
writerToken?: string;
|
|
7
|
+
gate(): Promise<void>;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
networkWaiting?(waiting: boolean): void;
|
|
10
|
+
}
|
|
11
|
+
export declare class DeliveryRequestError extends Error {
|
|
12
|
+
readonly retryable: boolean;
|
|
13
|
+
readonly retryAfter: number;
|
|
14
|
+
constructor(message: string, retryable?: boolean, retryAfter?: number);
|
|
15
|
+
}
|
|
16
|
+
export declare function checkCancelled(control?: UploadControl): void;
|
|
17
|
+
export declare function deliveryGate(control?: UploadControl): Promise<void>;
|
|
18
|
+
export declare function waitForRetry(ms: number, signal?: AbortSignal): Promise<void>;
|
|
19
|
+
export declare function retryDelivery<T>(operation: () => Promise<T>, control?: UploadControl, policy?: {
|
|
20
|
+
maxElapsedMs: number;
|
|
21
|
+
baseDelayMs: number;
|
|
22
|
+
}): Promise<T>;
|
|
23
|
+
export declare function deliveryJson<T>(url: string, body: Record<string, unknown>, control?: UploadControl, retry?: boolean): Promise<T>;
|
|
24
|
+
/** La callback viene richiamata a ogni tentativo: le firme scadute non vengono riusate. */
|
|
25
|
+
export declare function deliveryPut(sign: () => Promise<string>, body: BodyInit, contentType: string, control?: UploadControl): Promise<string>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** Richieste recuperabili delle consegne. Usare retry solo per operazioni idempotenti. */
|
|
2
|
+
export class UploadCancelled extends Error {
|
|
3
|
+
constructor() { super("Caricamento annullato"); this.name = "UploadCancelled"; }
|
|
4
|
+
}
|
|
5
|
+
export class DeliveryRequestError extends Error {
|
|
6
|
+
retryable;
|
|
7
|
+
retryAfter;
|
|
8
|
+
constructor(message, retryable = false, retryAfter = 0) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.retryable = retryable;
|
|
11
|
+
this.retryAfter = retryAfter;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function checkCancelled(control) {
|
|
15
|
+
if (control?.signal?.aborted)
|
|
16
|
+
throw new UploadCancelled();
|
|
17
|
+
}
|
|
18
|
+
export async function deliveryGate(control) {
|
|
19
|
+
checkCancelled(control);
|
|
20
|
+
if (!control)
|
|
21
|
+
return;
|
|
22
|
+
let abort;
|
|
23
|
+
try {
|
|
24
|
+
await Promise.race([
|
|
25
|
+
control.gate(),
|
|
26
|
+
new Promise((_, reject) => {
|
|
27
|
+
abort = () => reject(new UploadCancelled());
|
|
28
|
+
control.signal?.addEventListener("abort", abort, { once: true });
|
|
29
|
+
if (control.signal?.aborted)
|
|
30
|
+
abort();
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
checkCancelled(control);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
if (abort)
|
|
37
|
+
control.signal?.removeEventListener("abort", abort);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function waitForRetry(ms, signal) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
if (signal?.aborted) {
|
|
43
|
+
reject(new UploadCancelled());
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, ms);
|
|
47
|
+
function abort() { clearTimeout(timer); reject(new UploadCancelled()); }
|
|
48
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export async function retryDelivery(operation, control, policy = { maxElapsedMs: 30 * 60_000, baseDelayMs: 1000 }) {
|
|
52
|
+
const started = Date.now();
|
|
53
|
+
let waiting = false;
|
|
54
|
+
try {
|
|
55
|
+
for (let attempt = 0;; attempt++) {
|
|
56
|
+
checkCancelled(control);
|
|
57
|
+
await deliveryGate(control);
|
|
58
|
+
try {
|
|
59
|
+
const result = await operation();
|
|
60
|
+
checkCancelled(control);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
checkCancelled(control);
|
|
65
|
+
if (!(error instanceof DeliveryRequestError) || !error.retryable ||
|
|
66
|
+
Date.now() - started >= policy.maxElapsedMs)
|
|
67
|
+
throw error;
|
|
68
|
+
if (!waiting) {
|
|
69
|
+
waiting = true;
|
|
70
|
+
control?.networkWaiting?.(true);
|
|
71
|
+
}
|
|
72
|
+
const delay = error.retryAfter || Math.min(30_000, policy.baseDelayMs * 2 ** Math.min(attempt, 5));
|
|
73
|
+
await waitForRetry(delay + Math.floor(Math.random() * policy.baseDelayMs / 4), control?.signal);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
if (waiting)
|
|
79
|
+
control?.networkWaiting?.(false);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Il timeout comprende anche la lettura della risposta, non solo gli header. */
|
|
83
|
+
async function request(url, init, read, control, timeoutMs = 60_000) {
|
|
84
|
+
checkCancelled(control);
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const abort = () => controller.abort();
|
|
87
|
+
control?.signal?.addEventListener("abort", abort, { once: true });
|
|
88
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
89
|
+
try {
|
|
90
|
+
let response;
|
|
91
|
+
try {
|
|
92
|
+
response = await fetch(url, { ...init, signal: controller.signal });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
checkCancelled(control);
|
|
96
|
+
throw new DeliveryRequestError("Connessione interrotta. Attendo e riprovo…", true);
|
|
97
|
+
}
|
|
98
|
+
return await read(response);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
checkCancelled(control);
|
|
102
|
+
if (controller.signal.aborted)
|
|
103
|
+
throw new DeliveryRequestError("La richiesta non risponde. Riprovo…", true);
|
|
104
|
+
if (error instanceof TypeError)
|
|
105
|
+
throw new DeliveryRequestError("Risposta interrotta. Riprovo…", true);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
control?.signal?.removeEventListener("abort", abort);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function httpError(res, message, renewSignature = false) {
|
|
114
|
+
const transient = [408, 429, 500, 502, 503, 504].includes(res.status) || (renewSignature && res.status === 403);
|
|
115
|
+
const header = res.headers.get("retry-after");
|
|
116
|
+
const seconds = Number(header);
|
|
117
|
+
const retryAfter = header ? (Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now()) : 0;
|
|
118
|
+
return new DeliveryRequestError(message || `Richiesta non riuscita (HTTP ${res.status})`, transient, Number.isFinite(retryAfter) ? Math.max(0, Math.min(60_000, retryAfter)) : 0);
|
|
119
|
+
}
|
|
120
|
+
export function deliveryJson(url, body, control, retry = true) {
|
|
121
|
+
const operation = () => request(url, {
|
|
122
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(control?.writerToken ? { ...body, writerToken: control.writerToken } : body),
|
|
123
|
+
}, async (res) => {
|
|
124
|
+
let value;
|
|
125
|
+
try {
|
|
126
|
+
value = await res.json();
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (!res.ok)
|
|
130
|
+
throw httpError(res);
|
|
131
|
+
if (error instanceof TypeError || res.headers.get("content-type")?.includes("application/json")) {
|
|
132
|
+
throw new DeliveryRequestError("La risposta si è interrotta. Riprovo…", true);
|
|
133
|
+
}
|
|
134
|
+
throw new DeliveryRequestError("Risposta non valida. Verifica l’accesso al pannello e riprendi il caricamento.");
|
|
135
|
+
}
|
|
136
|
+
if (!res.ok || value.ok !== true) {
|
|
137
|
+
const error = httpError(res, value.error);
|
|
138
|
+
throw value.retryable === false ? new DeliveryRequestError(error.message) : error;
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}, control);
|
|
142
|
+
return retry ? retryDelivery(operation, control) : operation();
|
|
143
|
+
}
|
|
144
|
+
/** La callback viene richiamata a ogni tentativo: le firme scadute non vengono riusate. */
|
|
145
|
+
export function deliveryPut(sign, body, contentType, control) {
|
|
146
|
+
return retryDelivery(async () => {
|
|
147
|
+
const url = await sign();
|
|
148
|
+
await deliveryGate(control);
|
|
149
|
+
return request(url, { method: "PUT", body, headers: { "Content-Type": contentType } }, async (res) => {
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
await res.body?.cancel();
|
|
152
|
+
throw httpError(res, undefined, true);
|
|
153
|
+
}
|
|
154
|
+
const etag = res.headers.get("etag") || "";
|
|
155
|
+
await res.body?.cancel();
|
|
156
|
+
return etag;
|
|
157
|
+
}, control, 30 * 60_000);
|
|
158
|
+
}, control);
|
|
159
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type UploadControl } from "./delivery-network.js";
|
|
2
|
+
export { UploadCancelled, type UploadControl } from "./delivery-network.js";
|
|
1
3
|
export interface DeliveryUploadProgress {
|
|
2
4
|
phase: "originals" | "derivatives" | "finalizing";
|
|
3
5
|
file: string;
|
|
@@ -20,7 +22,7 @@ export interface DeliveryManifestEntry {
|
|
|
20
22
|
height?: number;
|
|
21
23
|
}
|
|
22
24
|
export interface DeliveryManifest {
|
|
23
|
-
version: 1;
|
|
25
|
+
version: 1 | 2;
|
|
24
26
|
createdAt: string;
|
|
25
27
|
entries: DeliveryManifestEntry[];
|
|
26
28
|
skipped: {
|
|
@@ -33,9 +35,11 @@ interface ZipMember {
|
|
|
33
35
|
length: number;
|
|
34
36
|
crc32: number;
|
|
35
37
|
}
|
|
36
|
-
interface UploadOptions {
|
|
38
|
+
export interface UploadOptions {
|
|
39
|
+
manifestVersion?: 1 | 2;
|
|
37
40
|
galleryEnabled: boolean;
|
|
38
41
|
webEnabled: boolean;
|
|
42
|
+
webFormat?: "webp" | "jpg";
|
|
39
43
|
}
|
|
40
44
|
interface UploadResult {
|
|
41
45
|
manifest: DeliveryManifest;
|
|
@@ -43,28 +47,43 @@ interface UploadResult {
|
|
|
43
47
|
sourceBytes: number;
|
|
44
48
|
storedBytes: number;
|
|
45
49
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
interface MemberCheckpoint {
|
|
51
|
+
name: string;
|
|
52
|
+
size: number;
|
|
53
|
+
read: number;
|
|
54
|
+
crc: number;
|
|
55
|
+
chain: string;
|
|
56
|
+
localOffset: number;
|
|
57
|
+
dataOffset: number;
|
|
58
|
+
time: number;
|
|
59
|
+
date: number;
|
|
51
60
|
}
|
|
52
61
|
export declare function uploadDeliveryPackage(deliveryId: string, packageId: string, selected: File[], options: UploadOptions, onProgress: (progress: DeliveryUploadProgress) => void, control?: UploadControl): Promise<UploadResult>;
|
|
53
62
|
interface ZipSink {
|
|
63
|
+
gate?(): Promise<void>;
|
|
54
64
|
write(data: Uint8Array): Promise<void>;
|
|
55
65
|
close(): Promise<void>;
|
|
56
66
|
}
|
|
57
67
|
export declare class ZipStream {
|
|
68
|
+
private utc;
|
|
58
69
|
private sink;
|
|
59
70
|
private offset;
|
|
60
71
|
private entries;
|
|
61
|
-
constructor(sink: ZipSink);
|
|
62
|
-
|
|
72
|
+
constructor(sink: ZipSink, utc?: boolean);
|
|
73
|
+
restore(entries: DeliveryManifestEntry[], files: File[], offset: number): void;
|
|
74
|
+
add(nameValue: string, blob: Blob, progress?: (read: number) => void, modified?: Date, checkpoint?: (current: MemberCheckpoint, offset: number) => Promise<void>, resume?: MemberCheckpoint): Promise<ZipMember>;
|
|
63
75
|
close(): Promise<void>;
|
|
76
|
+
private sinkGate;
|
|
64
77
|
private push;
|
|
65
78
|
}
|
|
66
79
|
export declare function setDeliveryRelativePath(file: File, path: string): void;
|
|
67
80
|
export declare function deliveryRelativeName(file: File): string;
|
|
81
|
+
/** Solo metadati: nessuna lettura dei file o richiesta di rete prima del rifiuto. */
|
|
82
|
+
export declare function preflightDeliverySelection(files: File[], options?: UploadOptions): {
|
|
83
|
+
sourceBytes: number;
|
|
84
|
+
archiveBytes: number;
|
|
85
|
+
manifestBytes: number;
|
|
86
|
+
};
|
|
68
87
|
export declare function deliverySelectionName(files: File[]): string;
|
|
69
88
|
export declare function uploadDeliveryHeroObject(deliveryId: string, packageId: string, file: File): Promise<{
|
|
70
89
|
type: "object";
|
|
@@ -74,5 +93,4 @@ export declare function uploadDeliveryHeroObject(deliveryId: string, packageId:
|
|
|
74
93
|
height: number;
|
|
75
94
|
bytes: number;
|
|
76
95
|
}>;
|
|
77
|
-
export declare function deliverySourceFingerprint(files: File[]): Promise<string>;
|
|
78
|
-
export {};
|
|
96
|
+
export declare function deliverySourceFingerprint(files: File[], version?: 1 | 2): Promise<string>;
|