kempo-media 0.3.0 → 0.3.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - **Dashboard widget.** A **Media** widget on the kempo admin dashboard showing the asset count and the latest uploads, with a **Upload media** button, declared in `widgets` in `kempo-config.json`. Needs a kempo with the dashboard; ignored on older versions.
8
12
  ### Changed
9
13
  - **Media now sits on kempo-files, with thumbnails from kempo-thumbs.** Before, this extension kept its own copy of everything: uploads in the site's `public/media/uploads/` (static serving, no permissions), and `sharp` thumbnails in `public/media/thumbnails/`, parallel to kempo-files' permission-checked library and kempo-thumbs' ffmpeg thumbnails. Anything consuming images had to handle both stacks. Now an asset is a kempo-files file plus a `kempoMediaAsset` row (kind, dimensions, alt text, display name), the **asset id is the file id** (no mapping table), the bytes live in kempo-files' `files/media/` folder, and a thumbnail is whatever kempo-thumbs made. One storage layer, one set of file permissions, one thumbnail path.
10
14
  - `kempo-config.json` declares `"dependencies": ["kempo-files", "kempo-thumbs"]`; kempo will not install this extension until both are enabled. kempo-thumbs is required rather than optional so every consumer can rely on `thumbnail` meaning the same thing. Requires `kempo-files` >= 0.1.5, which adds `uploadFile`, caller-chosen ids and download caching.
package/README.md CHANGED
@@ -158,3 +158,7 @@ npm test
158
158
  - **Only assets uploaded through this library are in it.** Other files in kempo-files do not appear in the media grid unless registered with `createMediaAsset`.
159
159
  - **A replaced public file can be cached for up to an hour** by browsers (kempo-files' cache lifetime).
160
160
  - **No usage tracking.** Deleting an asset does not check whether a page links to it.
161
+
162
+ ## Dashboard widget
163
+
164
+ Adds a **Media** widget to the kempo admin dashboard showing the asset count and the latest uploads, with a **Upload media** button. It is declared in `widgets` in `kempo-config.json` and registered when the extension is installed or updated; people only see it if they hold its permission, and can hide, reorder or resize it from the dashboard's Customize dialog. Needs a kempo with the dashboard (see kempo's `docs/dashboard.md`); on an older kempo the entry is ignored.
@@ -1,3 +1,3 @@
1
- <content id="kempo-media-nav" name="Media" owner="kempo-media" enabled="true" locked="true" location="admin-nav-extensions" priority="0">
2
- <k-aside-item icon="image" group="Media" group-icon="image" href="/admin/extension/kempo-media/" no-expand>Media</k-aside-item>
1
+ <content id="kempo-media-nav" name="Media" owner="kempo-media" enabled="true" locked="true" location="admin-nav-extensions" priority="45">
2
+ <k-aside-item icon="image" group="Files" href="/admin/extension/kempo-media/" no-expand>Media</k-aside-item>
3
3
  </content>
package/kempo-config.json CHANGED
@@ -3,6 +3,22 @@
3
3
  "public-scope": "kempo-media",
4
4
  "schema": "./server/db/schema.js",
5
5
  "dependencies": ["kempo-files", "kempo-thumbs"],
6
+ "widgets": [
7
+ {
8
+ "key": "library",
9
+ "title": "Media",
10
+ "description": "How many media assets there are and the latest uploads.",
11
+ "icon": "image",
12
+ "element": "k-media-widget",
13
+ "module": "/kempo-media/components/MediaWidget.js",
14
+ "defaultWidth": 2,
15
+ "defaultHeight": 2,
16
+ "minWidth": 1,
17
+ "minHeight": 2,
18
+ "permission": "media:browse",
19
+ "priority": 73
20
+ }
21
+ ],
6
22
  "permissions": [
7
23
  { "name": "media:browse", "description": "List and view media library assets" },
8
24
  { "name": "media:upload", "description": "Upload new media assets" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-media",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Media library extension for kempo — upload and manage images, video, audio, 3D models, and archives, stored in kempo-files with thumbnails from kempo-thumbs",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant",
@@ -0,0 +1,50 @@
1
+ import DashboardWidget from '/kempo/components/DashboardWidget.js';
2
+ import { html, nothing } from '/kempo-ui/lit-all.min.js';
3
+ import { getMediaAssets } from '/kempo-media/sdk.js';
4
+
5
+ /*
6
+ The size of the media library and the most recent uploads.
7
+ */
8
+ export default class MediaWidget extends DashboardWidget {
9
+ static properties = {
10
+ ...DashboardWidget.properties,
11
+ total: { state: true },
12
+ recent: { state: true }
13
+ };
14
+
15
+ constructor(){
16
+ super();
17
+ this.total = 0;
18
+ this.recent = [];
19
+ this.cta = { label: 'Upload media', icon: 'upload', href: '/admin/extension/kempo-media/', permission: 'media:upload' };
20
+ }
21
+
22
+ /*
23
+ Public Methods
24
+ */
25
+
26
+ load = async () => {
27
+ const { assets, total } = await this.unwrap(getMediaAssets({ limit: 9 }));
28
+ this.total = total;
29
+ this.recent = assets;
30
+ };
31
+
32
+ /*
33
+ Rendering
34
+ */
35
+
36
+ renderContent(){
37
+ if(!this.total) return html`<p class="tc-muted">No media yet.</p>`;
38
+ const rows = this.recent.slice(0, this.height * 3);
39
+ return html`
40
+ <div class="h2 m0">${this.total}</div>
41
+ <div class="small tc-muted mb">${this.total === 1 ? 'Asset' : 'Assets'}</div>
42
+ ${rows.length ? html`<div class="small tc-muted mbq">Latest</div>` : nothing}
43
+ ${rows.map(asset => html`
44
+ <div class="d-f pyq bb"><span class="flex">${asset.originalName || asset.name || asset.id}</span><span class="small tc-muted">${asset.kind}</span></div>
45
+ `)}
46
+ `;
47
+ }
48
+ }
49
+
50
+ customElements.define('k-media-widget', MediaWidget);
@@ -0,0 +1,85 @@
1
+ import path from 'path';
2
+ import { existsSync } from 'fs';
3
+ import { readFile } from 'fs/promises';
4
+ import { fileURLToPath, pathToFileURL } from 'url';
5
+
6
+ /*
7
+ This extension adds widgets to the kempo admin dashboard through `widgets` in kempo-config.json. Core
8
+ refuses a definition it could not render (and the extension would fail to install), and a module path
9
+ that points at nothing only shows up as a broken tile, so both are checked here.
10
+
11
+ The rules are checked inline so this works against any installed kempo, including one released before
12
+ the dashboard existed. When the installed kempo does have the dashboard, its own validation also runs.
13
+ */
14
+
15
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const config = JSON.parse(await readFile(path.join(root, 'kempo-config.json'), 'utf8'));
17
+ const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
18
+ const core = await import(pathToFileURL(path.join(root, 'node_modules', 'kempo', 'server', 'utils', 'dashboard', 'helpers.js')).href).catch(() => null);
19
+
20
+ const scope = `/${pkg.kempo?.['public-scope'] || config['public-scope']}/`;
21
+ const permissions = new Set((config.permissions || []).map(p => p.name));
22
+
23
+ const ELEMENT = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
24
+ const KEY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
25
+ const LOCAL_PATH = /^\/(?![/\\])[^\\\s\u0000-\u001f\u007f]*$/;
26
+ const blocks = (value, max) => Number.isInteger(value) && value >= 1 && value <= max;
27
+
28
+ const problems = widget => {
29
+ const { key, title, element, module, defaultWidth = 2, defaultHeight = 1, minWidth = 1, maxWidth = 4, minHeight = 1, maxHeight = 3, actions = [] } = widget;
30
+ const found = [];
31
+ if(typeof key !== 'string' || !KEY.test(key)) found.push('key must be letters, numbers, ".", "_" or "-"');
32
+ if(typeof title !== 'string' || !title.trim() || title.length > 100) found.push('title is required, at most 100 characters');
33
+ if(typeof element !== 'string' || !ELEMENT.test(element)) found.push('element must be a custom element name such as "my-ext-widget"');
34
+ if(typeof module !== 'string' || !LOCAL_PATH.test(module)) found.push('module must be a path on this site beginning with a single "/"');
35
+ if(!blocks(minWidth, 4) || !blocks(maxWidth, 4) || minWidth > maxWidth) found.push('minWidth and maxWidth must be 1 to 4, min no greater than max');
36
+ if(!blocks(minHeight, 3) || !blocks(maxHeight, 3) || minHeight > maxHeight) found.push('minHeight and maxHeight must be 1 to 3, min no greater than max');
37
+ if(!Number.isInteger(defaultWidth) || defaultWidth < minWidth || defaultWidth > maxWidth) found.push('defaultWidth must be within the width bounds');
38
+ if(!Number.isInteger(defaultHeight) || defaultHeight < minHeight || defaultHeight > maxHeight) found.push('defaultHeight must be within the height bounds');
39
+ if(!Array.isArray(actions) || actions.length > 3 || actions.some(action => !action?.label || !LOCAL_PATH.test(action?.href))) found.push('actions must be at most 3 { label, href } with a site path');
40
+ return found;
41
+ };
42
+
43
+ export default {
44
+ 'kempo-config.json declares at least one widget': async ({ pass, fail }) => {
45
+ if(!Array.isArray(config.widgets) || !config.widgets.length) return fail('no widgets declared');
46
+ pass(`${config.widgets.length} widget(s)`);
47
+ },
48
+
49
+ 'every declared widget is a definition the dashboard can render': async ({ pass, fail }) => {
50
+ for(const widget of config.widgets){
51
+ const found = problems(widget);
52
+ if(found.length) return fail(`${widget.key}: ${found.join('; ')}`);
53
+ if(core){
54
+ const [error] = core.normalizeWidget({ ...widget, owner: pkg.name });
55
+ if(error) return fail(`${widget.key}: kempo core refuses it: ${error.msg}`);
56
+ }
57
+ }
58
+ pass(core ? 'checked inline and by kempo core' : 'checked inline');
59
+ },
60
+
61
+ 'every widget module is served from the public scope and exists': async ({ pass, fail }) => {
62
+ for(const { key, module } of config.widgets){
63
+ if(!module.startsWith(scope)) return fail(`${key}: ${module} is outside ${scope}`);
64
+ if(!existsSync(path.join(root, 'public', module.slice(scope.length)))) return fail(`${key}: ${module} does not exist under public/`);
65
+ }
66
+ pass();
67
+ },
68
+
69
+ 'a widget permission is one the extension declares, or from core or a dependency when it declares none': async ({ pass, fail }) => {
70
+ for(const { key, permission } of config.widgets){
71
+ if(permission && permissions.size && !permissions.has(permission) && !permission.startsWith('system:')){
72
+ return fail(`${key}: permission "${permission}" is not declared by this extension`);
73
+ }
74
+ }
75
+ pass();
76
+ },
77
+
78
+ 'every widget module defines the element it is registered with': async ({ pass, fail }) => {
79
+ for(const { key, module, element } of config.widgets){
80
+ const source = await readFile(path.join(root, 'public', module.slice(scope.length)), 'utf8');
81
+ if(!source.includes(`customElements.define('${element}'`)) return fail(`${key}: ${module} never defines <${element}>`);
82
+ }
83
+ pass();
84
+ },
85
+ };