@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1
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/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
package/images-page.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The images workspace page: a gallery of thumbnails, the viewer in the editor modal, and the thumbnail
|
|
3
|
+
* and dashboard-screenshot files they show.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const { execFile } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const { esc } = require('./html');
|
|
12
|
+
const { humanSize, humanTime } = require('./format');
|
|
13
|
+
const { iconHtml } = require('./icons');
|
|
14
|
+
const { ROOT, hubDomain, workspaceDir } = require('./workspaces');
|
|
15
|
+
const lists = require('./lists');
|
|
16
|
+
const images = require('./images');
|
|
17
|
+
|
|
18
|
+
const TARGET = '#webship-workspace-projects';
|
|
19
|
+
const encodePath = (rel) => String(rel).split('/').map(encodeURIComponent).join('/');
|
|
20
|
+
|
|
21
|
+
/** Where the browser loads an image of a source from. */
|
|
22
|
+
function fileUrl(src, rel) {
|
|
23
|
+
return src === images.APP_SHOTS
|
|
24
|
+
? `/fragments/images/app-file/${encodePath(rel)}`
|
|
25
|
+
: `/files/${encodeURIComponent(src)}/${encodePath(rel)}`;
|
|
26
|
+
}
|
|
27
|
+
const thumbUrl = (src, rel) => `/fragments/images/thumb?v=2&src=${encodeURIComponent(src)}&path=${encodeURIComponent(rel)}`;
|
|
28
|
+
const sourceLabel = (src) => (src === images.APP_SHOTS ? 'dashboard screenshots' : src);
|
|
29
|
+
|
|
30
|
+
/** The list the state asks for, in order: the same one the gallery draws and the viewer steps through. */
|
|
31
|
+
function currentList(st, rel) {
|
|
32
|
+
const all = st.scope === 'all';
|
|
33
|
+
const base = all
|
|
34
|
+
? images.scanEverywhere()
|
|
35
|
+
: images.listFolder(workspaceDir('images'), rel).images.map((im) => ({ ...im, src: 'images' }));
|
|
36
|
+
const q = (st.q || '').toLowerCase();
|
|
37
|
+
const kind = images.TYPES.includes(st.kind) ? st.kind : '';
|
|
38
|
+
const cmp = (lists.SORTS[st.sort] || lists.SORTS.newest).cmp;
|
|
39
|
+
return base
|
|
40
|
+
.filter((im) => (!kind || im.type === kind) && (!q || `${im.src}/${im.rel}`.toLowerCase().includes(q)))
|
|
41
|
+
.sort(cmp);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function query(st, rel, extra = {}) {
|
|
45
|
+
const qs = new URLSearchParams();
|
|
46
|
+
if (st.q) qs.set('q', st.q);
|
|
47
|
+
if (st.sort && st.sort !== lists.defaultSort()) qs.set('sort', st.sort);
|
|
48
|
+
if (images.TYPES.includes(st.kind)) qs.set('kind', st.kind);
|
|
49
|
+
if (st.scope === 'all') qs.set('scope', 'all');
|
|
50
|
+
else if (rel) qs.set('path', rel);
|
|
51
|
+
for (const [k, v] of Object.entries(extra)) qs.set(k, v);
|
|
52
|
+
return qs.toString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One image as a card in the shared file list: the same row the docs and videos lists draw. */
|
|
56
|
+
function cardHtml(im, st, rel) {
|
|
57
|
+
const view = `/fragments/images/view?${query(st, rel, { src: im.src, file: im.rel })}`;
|
|
58
|
+
const url = fileUrl(im.src, im.rel);
|
|
59
|
+
const dims = im.width && im.height ? `${im.width}×${im.height}` : '';
|
|
60
|
+
const open = `hx-get="${esc(view)}" hx-target="#editor-modal-body" hx-swap="innerHTML"`;
|
|
61
|
+
const where = st.scope === 'all'
|
|
62
|
+
? `<span class="fx-source" title="${esc(`${sourceLabel(im.src)}/${im.rel}`)}">${esc(sourceLabel(im.src))}${im.rel.includes('/') ? ` › ${esc(im.rel.split('/').slice(0, -1).join('/'))}` : ''}</span>`
|
|
63
|
+
: '';
|
|
64
|
+
const own = im.src === 'images';
|
|
65
|
+
const menu = `<div class="uk-inline fx-menu">
|
|
66
|
+
<button class="uk-button uk-button-link fx-more" type="button" title="More for ${esc(im.name)}"
|
|
67
|
+
aria-label="More actions for ${esc(im.name)}">${iconHtml('tabler:dots', 0.8)}</button>
|
|
68
|
+
<div uk-dropdown="mode: click; pos: bottom-right"><ul class="uk-nav uk-dropdown-nav">
|
|
69
|
+
<li><a href="${esc(url)}" target="_blank" rel="noopener"><span uk-icon="icon: link-external; ratio: .7"></span> Open in a new tab</a></li>
|
|
70
|
+
<li><a href="${esc(url)}${url.includes('?') ? '&' : '?'}download=1"><span uk-icon="icon: download; ratio: .7"></span> Download ${esc(im.type.toUpperCase())} · ${esc(humanSize(im.size))}</a></li>
|
|
71
|
+
${own ? `<li class="uk-nav-divider"></li>
|
|
72
|
+
<li><a href class="uk-text-danger arm-step" hx-post="/actions/image-delete" hx-trigger="confirmed-remove"
|
|
73
|
+
hx-vals="${esc(JSON.stringify({ workspace: 'images', file: im.rel, confirm: 'yes' }))}"
|
|
74
|
+
hx-target="#webship-workspace-output" hx-swap="innerHTML" title="Delete ${esc(im.rel)}"><span uk-icon="icon: trash; ratio: .7"></span> Delete</a></li>` : ''}
|
|
75
|
+
</ul></div>
|
|
76
|
+
</div>`;
|
|
77
|
+
return `
|
|
78
|
+
<div class="fx-row fx-file has-thumb img-row">
|
|
79
|
+
<span class="fx-thumb is-image${im.type === 'svg' ? ' is-vector' : ''}" role="button" tabindex="0" title="View ${esc(im.name)}"
|
|
80
|
+
${open} hx-trigger="click, keyup[key=='Enter']"><img src="${esc(thumbUrl(im.src, im.rel))}" alt="" loading="lazy" decoding="async">${
|
|
81
|
+
dims ? `<span class="fx-duration fx-dims">${esc(dims)}</span>` : ''}</span>
|
|
82
|
+
<span class="fx-icon">${iconHtml('tabler:photo', 0.85)}</span>
|
|
83
|
+
<span class="fx-name" title="${esc(im.name)}">${esc(im.name)}${where}</span>
|
|
84
|
+
<span class="fx-size">${esc(humanSize(im.size))}${dims ? ` · ${esc(dims)}` : ''}</span>
|
|
85
|
+
<span class="fx-date">${esc(humanTime(im.mtime))}</span>
|
|
86
|
+
<span class="fx-actions"><button class="uk-button uk-button-default uk-button-small fx-open" title="View ${esc(im.name)}" ${open}><span uk-icon="icon: search; ratio: .7"></span> ${esc(im.type.toUpperCase())}</button>${menu}</span>
|
|
87
|
+
</div>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A folder as the docs and videos lists draw one: the whole row opens it, and it says how many images are inside. */
|
|
91
|
+
function folderHtml(f) {
|
|
92
|
+
const go = `/fragments/images/items?path=${encodeURIComponent(f.rel)}`;
|
|
93
|
+
const push = `/images?path=${encodePath(f.rel)}`;
|
|
94
|
+
return `
|
|
95
|
+
<a class="fx-row fx-folder" href="${esc(push)}" hx-get="${esc(go)}" hx-target="${TARGET}" hx-swap="innerHTML" hx-push-url="${esc(push)}"
|
|
96
|
+
title="Open ${esc(f.rel)}">
|
|
97
|
+
<span class="fx-icon">${iconHtml('tabler:folder', 0.9)}</span>
|
|
98
|
+
<span class="fx-name">${esc(f.name)}</span>
|
|
99
|
+
<span class="fx-size">${esc([f.count ? `${f.count} image${f.count === 1 ? '' : 's'}` : '',
|
|
100
|
+
f.folders ? `${f.folders} folder${f.folders === 1 ? '' : 's'}` : ''].filter(Boolean).join(', ') || 'empty')}</span>
|
|
101
|
+
<span class="fx-date">${f.mtime ? esc(humanTime(f.mtime)) : ''}</span>
|
|
102
|
+
<span class="fx-actions"><span class="fx-go">${iconHtml('tabler:chevron-right', 0.8)}</span></span>
|
|
103
|
+
</a>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The whole gallery, as the items fragment and the page both draw it, in the shared file-list shape. */
|
|
107
|
+
function galleryHtml(st, sub, { folderDocs = '', openWith = '', viewSwitcher = '' } = {}) {
|
|
108
|
+
const all = st.scope === 'all';
|
|
109
|
+
const folder = all ? { folders: [], rel: '' } : images.listFolder(workspaceDir('images'), sub);
|
|
110
|
+
const rel = all ? '' : folder.rel || '';
|
|
111
|
+
const list = currentList(st, rel);
|
|
112
|
+
const folders = all || st.q ? [] : folder.folders.slice().sort((a, b) => a.name.localeCompare(b.name));
|
|
113
|
+
const p = lists.paginate(list, st);
|
|
114
|
+
const url = '/fragments/images/items';
|
|
115
|
+
const qs = query(st, rel);
|
|
116
|
+
const noun = all ? 'media images' : 'images';
|
|
117
|
+
const pager = lists.listPagerHtml(`${url}${qs ? `?${qs}` : ''}`, TARGET, p, st, noun);
|
|
118
|
+
const filters = `<label class="uk-text-meta">Show
|
|
119
|
+
<select class="uk-select uk-form-small" name="scope" aria-label="Which images"
|
|
120
|
+
hx-get="${url}" hx-target="${TARGET}" hx-swap="innerHTML"
|
|
121
|
+
hx-trigger="change" hx-include="closest .list-controls" hx-vals='{"page":"1","path":""}'>
|
|
122
|
+
<option value="here"${all ? '' : ' selected'}>This workspace</option>
|
|
123
|
+
<option value="all"${all ? ' selected' : ''}>All media images</option>
|
|
124
|
+
</select></label>
|
|
125
|
+
<label class="uk-text-meta">Type
|
|
126
|
+
<select class="uk-select uk-form-small" name="kind" aria-label="Image type"
|
|
127
|
+
hx-get="${url}" hx-target="${TARGET}" hx-swap="innerHTML"
|
|
128
|
+
hx-trigger="change" hx-include="closest .list-controls" hx-vals='{"page":"1"}'>
|
|
129
|
+
<option value="all">All</option>${images.TYPES.map((t) => `<option value="${t}"${st.kind === t ? ' selected' : ''}>${t.toUpperCase()}</option>`).join('')}
|
|
130
|
+
</select></label>`;
|
|
131
|
+
const trail = !all && rel
|
|
132
|
+
? `<nav class="folder-crumbs" aria-label="Folder">${[{ name: 'Images', rel: '' }, ...rel.split('/').map((s, i, a) => ({ name: s, rel: a.slice(0, i + 1).join('/') }))]
|
|
133
|
+
.map((c, i, a) => (i === a.length - 1
|
|
134
|
+
? `<span class="crumb is-here" aria-current="page">${esc(c.name)}</span>`
|
|
135
|
+
: `<a class="crumb" href="/images${c.rel ? `?path=${esc(encodePath(c.rel))}` : ''}" hx-get="${url}${c.rel ? `?path=${encodeURIComponent(c.rel)}` : ''}"
|
|
136
|
+
hx-target="${TARGET}" hx-swap="innerHTML" hx-push-url="/images${c.rel ? `?path=${esc(encodePath(c.rel))}` : ''}">${esc(c.name)}</a>`)).join('<span class="crumb-sep" aria-hidden="true">›</span>')}</nav>`
|
|
137
|
+
: '';
|
|
138
|
+
const total = list.reduce((n, im) => n + im.size, 0);
|
|
139
|
+
const heading = all ? 'All media images' : (rel ? rel.split('/').pop() : 'Images');
|
|
140
|
+
const capped = all ? (images.scanEverywhere().capped || []) : [];
|
|
141
|
+
const summary = `<p class="uk-text-meta fx-summary">${[
|
|
142
|
+
folders.length ? `${folders.length} folder${folders.length === 1 ? '' : 's'}` : '',
|
|
143
|
+
list.length ? `${list.length} image${list.length === 1 ? '' : 's'}` : '',
|
|
144
|
+
total ? humanSize(total) : '',
|
|
145
|
+
].filter(Boolean).map(esc).join(' · ') || 'This folder is empty.'}${
|
|
146
|
+
all ? ' · read-only, from docs, videos, audio, specs, designs and the dashboard screenshots' : ''}${
|
|
147
|
+
capped.length ? ` · the first ${images.SCAN_MAX} of ${esc(capped.map(sourceLabel).join(', '))}` : ''}</p>`;
|
|
148
|
+
// Folders lead the first page only; paging is over the images.
|
|
149
|
+
const rows = [...(p.page === 1 || !p.page ? folders.map(folderHtml) : []), ...p.slice.map((im) => cardHtml(im, st, rel))];
|
|
150
|
+
return `
|
|
151
|
+
${trail}
|
|
152
|
+
<div class="cards-head fx-head">
|
|
153
|
+
<div>
|
|
154
|
+
<h3 class="uk-margin-remove">${esc(heading)} <span class="uk-badge">${list.length + folders.length}</span></h3>
|
|
155
|
+
${summary}
|
|
156
|
+
</div>
|
|
157
|
+
${viewSwitcher}
|
|
158
|
+
</div>
|
|
159
|
+
${all ? '' : `<div class="uk-margin-small-bottom items-open-with">${openWith}</div>`}
|
|
160
|
+
${lists.listControlsHtml(url, TARGET, st, noun, filters, { ...(rel ? { path: rel } : {}) }, { fileSorts: true })}
|
|
161
|
+
${st.q ? `<p class="uk-text-meta">${p.total} of ${list.length} ${esc(noun)} match “${esc(st.q)}”.</p>` : ''}
|
|
162
|
+
${pager}
|
|
163
|
+
${rows.length ? `<div class="file-list img-list">${rows.join('')}</div>`
|
|
164
|
+
: `<p class="uk-text-meta">${st.q || images.TYPES.includes(st.kind) ? `No ${esc(noun)} match.` : `No images here yet. Put them in <code>${esc(path.join(workspaceDir('images'), rel).replace(process.env.HOME || '', '~'))}</code> and they appear here.`}</p>`}
|
|
165
|
+
${pager}
|
|
166
|
+
${all ? '' : folderDocs}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ------------------------------------------------------------------ viewer --
|
|
170
|
+
|
|
171
|
+
function infoRow(label, value) {
|
|
172
|
+
return value ? `<dt>${esc(label)}</dt><dd>${value}</dd>` : '';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The viewer for one image, with its neighbours in the list it was opened from. */
|
|
176
|
+
/** An SVG's markup to copy: the file as it is, up to 1 MB. */
|
|
177
|
+
function svgMarkup(abs) {
|
|
178
|
+
try { return fs.statSync(abs).size <= 1024 * 1024 ? fs.readFileSync(abs, 'utf8') : ''; } catch (_) { return ''; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The picture in the viewer. An SVG is shown as an image, so it animates but runs nothing; it starts when a quarter
|
|
183
|
+
* of it is in view, and Replay starts it again. Its PNG preview stands in for it when the reader asks for less motion.
|
|
184
|
+
*/
|
|
185
|
+
function svgStage(im, url, made, abs) {
|
|
186
|
+
if (im.type !== 'svg') return `<img src="${esc(url)}" alt="${esc(im.name)}">`;
|
|
187
|
+
const alt = (made && made.alt) || im.name;
|
|
188
|
+
const threshold = made && made.motion && Number.isFinite(Number(made.motion.threshold)) ? Number(made.motion.threshold) : 0.25;
|
|
189
|
+
const png = made && made.png && fs.existsSync(path.join(path.dirname(abs), made.png)) ? url.replace(/[^/?]+\.svg(\?|$)/, `${encodeURIComponent(made.png)}$1`) : '';
|
|
190
|
+
return `<picture>${png ? `<source media="(prefers-reduced-motion: reduce)" srcset="${esc(png)}">` : ''}<img data-svg-motion data-threshold="${threshold}" data-src="${esc(url)}" src="${esc(url)}" alt="${esc(alt)}"></picture>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function viewerHtml(st, rel, src, file) {
|
|
194
|
+
const root = images.sourceRoot(src);
|
|
195
|
+
const where = root ? images.safeIn(root, file) : null;
|
|
196
|
+
if (!where || !images.IMAGE_RE.test(where.abs)) return null;
|
|
197
|
+
const im = { ...images.describe(where.abs, where.rel), src };
|
|
198
|
+
const list = currentList(st, rel);
|
|
199
|
+
const i = list.findIndex((x) => x.src === src && x.rel === where.rel);
|
|
200
|
+
const step = (d) => (i < 0 || !list.length ? null : list[(i + d + list.length) % list.length]);
|
|
201
|
+
const prev = step(-1);
|
|
202
|
+
const next = step(1);
|
|
203
|
+
const navBtn = (n, label, glyph, attr) => (n && list.length > 1
|
|
204
|
+
? `<button type="button" class="uk-button uk-button-default uk-button-small" ${attr}
|
|
205
|
+
hx-get="/fragments/images/view?${esc(query(st, rel, { src: n.src, file: n.rel }))}" hx-target="#editor-modal-body" hx-swap="innerHTML"
|
|
206
|
+
title="${esc(label)}: ${esc(n.name)}" aria-label="${esc(label)}">${glyph}</button>`
|
|
207
|
+
: '');
|
|
208
|
+
const url = fileUrl(src, im.rel);
|
|
209
|
+
const abs = `https://${hubDomain()}${url}`;
|
|
210
|
+
const made = images.sidecarOf(where.abs);
|
|
211
|
+
const own = src === 'images';
|
|
212
|
+
const vals = (o) => esc(JSON.stringify({ workspace: 'images', file: im.rel, ...o }));
|
|
213
|
+
return `
|
|
214
|
+
<div class="editor-card img-viewer" data-img-viewer>
|
|
215
|
+
<div class="img-viewer-head">
|
|
216
|
+
<h3 class="uk-margin-remove" title="${esc(im.name)}">${esc(im.name)}</h3>
|
|
217
|
+
<span class="uk-text-meta">${i >= 0 ? `${i + 1} of ${list.length}` : ''}</span>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="img-stage is-fit" data-img-stage>
|
|
220
|
+
${svgStage(im, url, made, where.abs)}
|
|
221
|
+
</div>
|
|
222
|
+
<div class="img-nav">
|
|
223
|
+
${navBtn(prev, 'Previous', '‹ Previous', 'data-img-prev')}
|
|
224
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" data-img-zoom title="Switch between fit and actual size">Actual size</button>
|
|
225
|
+
${navBtn(next, 'Next', 'Next ›', 'data-img-next')}
|
|
226
|
+
</div>
|
|
227
|
+
<dl class="img-info">
|
|
228
|
+
${infoRow('Dimensions', im.width ? `${im.width} × ${im.height} px` : '')}
|
|
229
|
+
${infoRow('Size', esc(humanSize(im.size)))}
|
|
230
|
+
${infoRow('Type', esc(im.type.toUpperCase()))}
|
|
231
|
+
${infoRow('Modified', esc(humanTime(im.mtime)))}
|
|
232
|
+
${infoRow('Where', `<code>${esc(`${sourceLabel(src)}/${im.rel}`)}</code>`)}
|
|
233
|
+
</dl>
|
|
234
|
+
${made ? `<div class="img-made">
|
|
235
|
+
<h4 class="uk-h6 uk-margin-small-bottom">How it was made</h4>
|
|
236
|
+
<dl class="img-info">
|
|
237
|
+
${infoRow('Summary', esc(String(made.summary || '')))}
|
|
238
|
+
${infoRow('Alt text', esc(String(made.alt || '')))}
|
|
239
|
+
${made.source && typeof made.source === 'object' ? infoRow('Edited from', `<a href="#" hx-get="/fragments/images/view?src=${esc(encodeURIComponent(made.source.workspace))}&file=${esc(encodeURIComponent(made.source.path))}" hx-target="#editor-modal-body" hx-swap="innerHTML"><code>${esc(`${sourceLabel(made.source.workspace)}/${made.source.path}`)}</code></a>`) : ''}
|
|
240
|
+
${typeof made.source === 'string' ? infoRow('Source', esc(made.source)) : ''}
|
|
241
|
+
${infoRow('Edit prompt', esc(String(made.edit_prompt || '')))}
|
|
242
|
+
${infoRow('Prompt', esc(String(made.prompt || '')))}
|
|
243
|
+
${infoRow('Provider', esc(String(made.provider || '')))}
|
|
244
|
+
${infoRow('Model', esc(String(made.model || '')))}
|
|
245
|
+
${infoRow('Style', esc(String(made.style || '')))}
|
|
246
|
+
${infoRow('Attempts', esc(String(made.attempts || '')))}
|
|
247
|
+
${infoRow('Palette', esc(String(made.palette || '')))}
|
|
248
|
+
${infoRow('Size', esc(String(made.size || '')))}
|
|
249
|
+
${infoRow('Quality', esc(String(made.quality || '')))}
|
|
250
|
+
${infoRow('Made', esc(String(made.created || '')))}
|
|
251
|
+
${infoRow('Cost', esc(String(made.cost || '')))}
|
|
252
|
+
${infoRow('Description', esc(made.described_by ? `${made.described_by}${made.description_cost ? `, ${made.description_cost}` : ''}` : String(made.description || '')))}
|
|
253
|
+
</dl></div>` : ''}
|
|
254
|
+
${own && !(made && made.summary) ? `<p class="img-describe">
|
|
255
|
+
<button type="button" class="uk-button uk-button-default uk-button-small arm-step" title="Describe ${esc(im.name)} with AI: a summary and an alt text (paid, about $0.001)"
|
|
256
|
+
hx-post="/actions/image-describe" hx-trigger="confirmed-remove" hx-vals="${vals({ confirm: 'yes' })}"
|
|
257
|
+
hx-target="#editor-modal-body" hx-swap="innerHTML">${iconHtml('tabler:sparkles', 0.7)} Describe it</button>
|
|
258
|
+
<span class="uk-text-meta">${made && made.description === 'not written' ? 'The description was not written.' : 'No description yet.'}</span></p>` : ''}
|
|
259
|
+
<div class="img-actions">
|
|
260
|
+
<a class="uk-button uk-button-primary uk-button-small" href="${esc(url)}${url.includes('?') ? '&' : '?'}download=1">${iconHtml('download', 0.7)} Download</a>
|
|
261
|
+
<a class="uk-button uk-button-default uk-button-small" href="${esc(url)}" target="_blank" rel="noopener">${iconHtml('link-external', 0.7)} Open in a new tab</a>
|
|
262
|
+
<button type="button" class="uk-button uk-button-default uk-button-small copy-cmd" data-copy="${esc(abs)}">${iconHtml('link', 0.7)} Copy link</button>
|
|
263
|
+
${im.type === 'svg' ? `<button type="button" class="uk-button uk-button-default uk-button-small" data-svg-replay>${iconHtml('refresh', 0.7)} Replay</button>
|
|
264
|
+
<button type="button" class="uk-button uk-button-default uk-button-small copy-cmd" data-copy="${esc(svgMarkup(where.abs))}">${iconHtml('code', 0.7)} Copy SVG markup</button>` : ''}
|
|
265
|
+
<button type="button" class="uk-button uk-button-default uk-button-small copy-cmd" data-copy="${esc(`![${String((made && made.alt) || im.name.replace(/\.[^.]*$/, '')).replace(/[[\]]/g, '')}](${abs})`)}">${iconHtml('code', 0.7)} Copy Markdown</button>
|
|
266
|
+
${own ? `<button type="button" class="uk-button uk-button-danger uk-button-small arm-step" title="Delete ${esc(im.rel)}"
|
|
267
|
+
hx-post="/actions/image-delete" hx-trigger="confirmed-remove" hx-vals="${vals({ confirm: 'yes' })}"
|
|
268
|
+
hx-target="#editor-modal-body" hx-swap="innerHTML">${iconHtml('trash', 0.7)} Delete</button>` : ''}
|
|
269
|
+
</div>
|
|
270
|
+
${own ? `<form class="img-rename" hx-post="/actions/image-rename" hx-target="#editor-modal-body" hx-swap="innerHTML">
|
|
271
|
+
<input type="hidden" name="workspace" value="images">
|
|
272
|
+
<input type="hidden" name="file" value="${esc(im.rel)}">
|
|
273
|
+
<label class="uk-form-label" for="img-rename-to">Rename</label>
|
|
274
|
+
<div class="uk-flex uk-flex-middle img-rename-row">
|
|
275
|
+
<input class="uk-input uk-form-small" id="img-rename-to" name="name" value="${esc(im.name)}" maxlength="120" required>
|
|
276
|
+
<button class="uk-button uk-button-default uk-button-small" type="submit">Rename</button>
|
|
277
|
+
</div>
|
|
278
|
+
</form>
|
|
279
|
+
${require('./images-create').editFormHtml(im.rel)}` : `<p class="uk-text-meta">This image belongs to <strong>${esc(sourceLabel(src))}</strong>; it is shown here read-only. An AI edit is saved into <code>images/edited/</code>.</p>`}
|
|
280
|
+
${require('./images-ai').editFormHtml(src, im.rel, im.type === 'svg')}
|
|
281
|
+
</div>`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// -------------------------------------------------------------- thumbnails --
|
|
285
|
+
|
|
286
|
+
const thumbDir = () => path.join(ROOT, 'core', 'workspace-app', '.cache', 'image-thumbs');
|
|
287
|
+
const THUMB_W = 640;
|
|
288
|
+
const THUMB_H = 480;
|
|
289
|
+
let running = 0;
|
|
290
|
+
const waiting = [];
|
|
291
|
+
|
|
292
|
+
function makeThumb(src, out) {
|
|
293
|
+
return new Promise((resolve) => {
|
|
294
|
+
const go = () => {
|
|
295
|
+
running += 1;
|
|
296
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
297
|
+
// [0] takes the first frame of a GIF; the size is a bound, never an enlargement.
|
|
298
|
+
// Filled to the card's own 4:3 and cropped from the top, where a screenshot's content starts, so a tall
|
|
299
|
+
// capture is a sharp crop rather than a sliver stretched to fit.
|
|
300
|
+
execFile('convert', [`${src}[0]`, '-auto-orient', '-thumbnail', `${THUMB_W}x${THUMB_H}^`, '-gravity', 'north',
|
|
301
|
+
'-extent', `${THUMB_W}x${THUMB_H}`, '-quality', '80', `webp:${out}`],
|
|
302
|
+
{ timeout: 20000 }, (err) => {
|
|
303
|
+
running -= 1;
|
|
304
|
+
if (waiting.length) waiting.shift()();
|
|
305
|
+
resolve(!err && fs.existsSync(out));
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
if (running < 3) go(); else waiting.push(go);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A small webp of an image, made once and kept; the original when it cannot be made or is already small. */
|
|
313
|
+
async function thumbFile(src, rel) {
|
|
314
|
+
const root = images.sourceRoot(src);
|
|
315
|
+
const where = root ? images.safeIn(root, rel) : null;
|
|
316
|
+
if (!where || !images.IMAGE_RE.test(where.abs)) return null;
|
|
317
|
+
const st = fs.statSync(where.abs);
|
|
318
|
+
if (images.typeOf(where.abs) === 'svg' || st.size < 60 * 1024) return { file: where.abs, original: true };
|
|
319
|
+
const key = crypto.createHash('sha1').update(`v2|${where.abs}|${st.size}|${st.mtimeMs}`).digest('hex');
|
|
320
|
+
const out = path.join(thumbDir(), `${key}.webp`);
|
|
321
|
+
if (fs.existsSync(out) || await makeThumb(where.abs, out)) return { file: out, original: false };
|
|
322
|
+
return { file: where.abs, original: true };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const TYPES_BY_EXT = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
326
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.avif': 'image/avif' };
|
|
327
|
+
|
|
328
|
+
function sendImage(res, file, { cache = false } = {}) {
|
|
329
|
+
const type = TYPES_BY_EXT[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
|
330
|
+
res.writeHead(200, {
|
|
331
|
+
'Content-Type': type, 'Content-Length': fs.statSync(file).size, 'X-Content-Type-Options': 'nosniff',
|
|
332
|
+
...(type === 'image/svg+xml' ? { 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}),
|
|
333
|
+
...(cache ? { 'Cache-Control': 'private, max-age=86400' } : {}),
|
|
334
|
+
});
|
|
335
|
+
fs.createReadStream(file).pipe(res);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The images workspace's own fragments. Returns true when it answered.
|
|
340
|
+
* `deps` carries what lives in server.js: the page state reader and the folder docs.
|
|
341
|
+
*/
|
|
342
|
+
async function fragment(req, res, pathname, deps) {
|
|
343
|
+
const q = new URL(req.url, 'http://localhost').searchParams;
|
|
344
|
+
if (pathname === '/fragments/images/items') {
|
|
345
|
+
const st = lists.readPageState(req, 'images/items');
|
|
346
|
+
lists.setPageCookie(res, st);
|
|
347
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
348
|
+
res.end(galleryHtml(st, st.path, deps.galleryDeps()));
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
if (pathname === '/fragments/images/view') {
|
|
352
|
+
const st = lists.readPageState(req);
|
|
353
|
+
const html = viewerHtml(st, st.path, q.get('src') || 'images', q.get('file') || '');
|
|
354
|
+
res.writeHead(html ? 200 : 404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
355
|
+
res.end(html || '<div class="msg error">That image is not there any more.</div>');
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
if (pathname === '/fragments/images/upload') {
|
|
359
|
+
// The folder the page is showing, so an upload lands where the person is looking.
|
|
360
|
+
let rel = q.get('path') || '';
|
|
361
|
+
try { rel = rel || new URL(String(req.headers['hx-current-url'] || ''), 'http://localhost').searchParams.get('path') || ''; } catch (_) { /* no page */ }
|
|
362
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
363
|
+
res.end(require('./images-create').uploadFormHtml(rel));
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
if (pathname === '/fragments/images/generate-form') {
|
|
367
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
368
|
+
res.end(require('./images-ai').formHtml());
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
if (pathname === '/fragments/images/provider-fields') {
|
|
372
|
+
const kind = q.get('kind') === 'edit' ? 'edit' : 'generate';
|
|
373
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
374
|
+
res.end(require('./images-ai').providerFieldsHtml(kind, q.get('provider') || '', kind === 'edit' ? { prefix: 'img-ai-edit', edit: true } : {}));
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
if (pathname === '/fragments/images/svg-form' || pathname === '/fragments/images/svg-provider-fields') {
|
|
378
|
+
const svg = require('./images-svg');
|
|
379
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
380
|
+
res.end(pathname.endsWith('svg-form') ? svg.formHtml() : svg.providerFieldsHtml(q.get('provider') || ''));
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
if (pathname === '/fragments/images/screenshot-form') {
|
|
384
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
385
|
+
res.end(require('./images-create').screenshotFormHtml());
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
if (pathname === '/fragments/images/thumb') {
|
|
389
|
+
const t = await thumbFile(q.get('src') || 'images', q.get('path') || '').catch(() => null);
|
|
390
|
+
if (!t) { res.writeHead(404); res.end('not found'); return true; }
|
|
391
|
+
sendImage(res, t.file, { cache: !t.original });
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
const app = pathname.match(/^\/fragments\/images\/app-file\/(.+)$/);
|
|
395
|
+
if (app) {
|
|
396
|
+
const where = images.safeIn(images.appShotsDir(), decodeURIComponent(app[1]));
|
|
397
|
+
if (!where || !images.IMAGE_RE.test(where.abs) || !fs.statSync(where.abs).isFile()) { res.writeHead(404); res.end('not found'); return true; }
|
|
398
|
+
if (q.get('download') === '1') {
|
|
399
|
+
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(where.abs).replace(/[^A-Za-z0-9._-]/g, '_')}"`);
|
|
400
|
+
}
|
|
401
|
+
sendImage(res, where.abs);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The images workspace's Create actions, for the rail and the create tiles. */
|
|
408
|
+
function createButtons(btn, modal) {
|
|
409
|
+
return [
|
|
410
|
+
btn('primary', 'upload', 'Upload images', `hx-get="/fragments/images/upload" ${modal}`, 'Drop or choose images to add to this workspace'),
|
|
411
|
+
btn('default', 'tabler:camera', 'Screenshot a page', `hx-get="/fragments/images/screenshot-form" ${modal}`, 'A picture of any web page, at a chosen width'),
|
|
412
|
+
btn('secondary', 'bolt', 'Generate with AI', `hx-get="/fragments/images/generate-form" ${modal}`, 'Describe a picture and an AI provider draws it; you see the plan and the cost first'),
|
|
413
|
+
btn('default', 'tabler:vector-bezier', 'Generate SVG', `hx-get="/fragments/images/svg-form" ${modal}`, 'A drawing as SVG code, still or animated, on brand; you see the plan and the cost first'),
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = { createButtons, galleryHtml, viewerHtml, currentList, fileUrl, thumbFile, fragment, sendImage, TYPES_BY_EXT };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The records beside a made image: <file>.json for the dashboard and a readable <stem>.md for people. No
|
|
3
|
+
* dependencies, so the host-side finish step of a Claude Code drawing can write them too.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const RECORD_MARK = '<!-- images: how this image was made -->';
|
|
10
|
+
|
|
11
|
+
/** The readable record beside an image: <stem>.md, or <file>.md when a note of the same stem is someone else's. */
|
|
12
|
+
function recordPath(abs) {
|
|
13
|
+
const stem = abs.replace(/\.[^./]*$/, '');
|
|
14
|
+
const md = `${stem}.md`;
|
|
15
|
+
try { if (fs.existsSync(md) && !fs.readFileSync(md, 'utf8').startsWith(RECORD_MARK)) return `${abs}.md`; } catch (_) { /* unreadable */ }
|
|
16
|
+
return md;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Write the sidecar and the readable record for an image. */
|
|
20
|
+
function writeRecords(abs, rec) {
|
|
21
|
+
const clean = Object.fromEntries(Object.entries(rec).filter(([, v]) => v !== undefined && v !== '' && v !== null));
|
|
22
|
+
fs.writeFileSync(`${abs}.json`, `${JSON.stringify(clean, null, 2)}\n`);
|
|
23
|
+
const name = path.basename(abs);
|
|
24
|
+
const title = name.replace(/\.[^.]*$/, '').replace(/[-_]+/g, ' ');
|
|
25
|
+
const line = (label, v) => (v ? `- **${label}:** ${String(v).replace(/\s+/g, ' ')}\n` : '');
|
|
26
|
+
const src = clean.source && typeof clean.source === 'object' ? `${clean.source.workspace}/${clean.source.path}` : '';
|
|
27
|
+
fs.writeFileSync(recordPath(abs), `${RECORD_MARK}
|
|
28
|
+
# ${title}
|
|
29
|
+
|
|
30
|
+
![${(clean.alt || title).replace(/[\[\]]/g, '')}](./${name})
|
|
31
|
+
|
|
32
|
+
${clean.summary ? `${clean.summary}\n\n` : '_No description written yet._\n\n'}**Alt text:** ${clean.alt || '_none yet_'}
|
|
33
|
+
|
|
34
|
+
## How it was made
|
|
35
|
+
|
|
36
|
+
${line('Prompt', clean.prompt)}${line('Edited from', src)}${line('Edit prompt', clean.edit_prompt)}${line('Source', typeof clean.source === 'string' ? clean.source : clean.from)}${line('Provider', clean.provider)}${line('Model', clean.model)}${line('Style', clean.style)}${line('Attempts', clean.attempts)}${line('Palette', clean.palette)}${line('Size', clean.size)}${line('Quality', clean.quality)}${line('PNG preview', clean.png)}${line('Made', clean.created)}${line('Cost', clean.cost)}${line('Description', clean.described_by ? `${clean.described_by}, ${clean.description_cost || ''}`.replace(/, $/, '') : '')}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { RECORD_MARK, recordPath, writeRecords };
|
package/images-render.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An SVG rendered to a PNG with headless Chrome or Chromium, whichever is on PATH. Animations are run to their end
|
|
3
|
+
* first (a virtual time budget), so a drawing that animates in is captured drawn. No dependencies: the host-side
|
|
4
|
+
* finish step of a Claude Code drawing uses it, and so does the dashboard container.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { execFileSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
/** The first Chrome on PATH, or null. */
|
|
13
|
+
function chrome() {
|
|
14
|
+
for (const bin of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) {
|
|
15
|
+
for (const dir of String(process.env.PATH || '').split(':')) {
|
|
16
|
+
const p = path.join(dir, bin);
|
|
17
|
+
if (dir && fs.existsSync(p)) return p;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Render an SVG to a PNG of the given size. Returns true when the PNG exists. */
|
|
24
|
+
function renderPng(svg, png, [w, h]) {
|
|
25
|
+
const bin = chrome();
|
|
26
|
+
if (!bin) return false;
|
|
27
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'images-chrome-'));
|
|
28
|
+
try {
|
|
29
|
+
execFileSync(bin, ['--headless=new', '--disable-gpu', '--no-first-run', '--hide-scrollbars', `--user-data-dir=${profile}`,
|
|
30
|
+
'--virtual-time-budget=6000', `--window-size=${w},${h}`, `--screenshot=${png}`, `file://${svg}`], { stdio: 'ignore', timeout: 60000 });
|
|
31
|
+
} catch (_) { /* the caller checks the PNG */ }
|
|
32
|
+
fs.rmSync(profile, { recursive: true, force: true });
|
|
33
|
+
return fs.existsSync(png);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { chrome, renderPng };
|