pinokiod 3.148.0 → 3.151.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const { isBinaryFile } = require('isbinaryfile');
5
+
6
+ const MAX_EDITABLE_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB safety limit
7
+
8
+ const createHttpError = (status, message) => {
9
+ const error = new Error(message);
10
+ error.status = status;
11
+ return error;
12
+ };
13
+
14
+ const sanitizeSegments = (value) => {
15
+ const normalized = path.posix.normalize(String(value || '').replace(/\\/g, '/'));
16
+ if (normalized === '.' || normalized === '') {
17
+ return [];
18
+ }
19
+ const segments = normalized.split('/').filter((segment) => segment && segment !== '.');
20
+ if (segments.some((segment) => segment === '..')) {
21
+ throw createHttpError(400, 'Invalid path');
22
+ }
23
+ return segments;
24
+ };
25
+
26
+ module.exports = function registerFileRoutes(app, { kernel, getTheme, exists }) {
27
+ if (!app || !kernel) {
28
+ throw new Error('File routes require an express app and kernel instance');
29
+ }
30
+
31
+ const router = express.Router();
32
+
33
+ const asyncHandler = (fn) => (req, res, next) => {
34
+ Promise.resolve(fn(req, res, next)).catch(next);
35
+ };
36
+
37
+
38
+ const ensureWorkspace = async (workspaceParam, rootParam) => {
39
+ const apiRoot = kernel.path('api');
40
+ if (rootParam) {
41
+ let decodedRoot;
42
+ try {
43
+ decodedRoot = Buffer.from(String(rootParam), 'base64').toString('utf8');
44
+ } catch (error) {
45
+ throw createHttpError(400, 'Invalid workspace descriptor');
46
+ }
47
+ if (!decodedRoot) {
48
+ throw createHttpError(400, 'Invalid workspace descriptor');
49
+ }
50
+ const normalizedRoot = path.resolve(decodedRoot);
51
+ if (!path.isAbsolute(normalizedRoot)) {
52
+ throw createHttpError(400, 'Workspace path must be absolute');
53
+ }
54
+ const relativeToHome = path.relative(kernel.homedir, normalizedRoot);
55
+ if (relativeToHome.startsWith('..') || path.isAbsolute(relativeToHome)) {
56
+ throw createHttpError(400, 'Workspace outside Pinokio home');
57
+ }
58
+ const relativeToApi = path.relative(apiRoot, normalizedRoot);
59
+ if (relativeToApi.startsWith('..') || path.isAbsolute(relativeToApi)) {
60
+ throw createHttpError(400, 'Workspace outside api directory');
61
+ }
62
+ const existsResult = await exists(normalizedRoot);
63
+ if (!existsResult) {
64
+ throw createHttpError(404, 'Workspace not found');
65
+ }
66
+ const segments = sanitizeSegments(workspaceParam || relativeToApi);
67
+ const effectiveSegments = segments.length > 0 ? segments : sanitizeSegments(relativeToApi);
68
+ const slugSegments = effectiveSegments.length > 0 ? effectiveSegments : [path.basename(normalizedRoot)];
69
+ return {
70
+ apiRoot,
71
+ segments: slugSegments,
72
+ workspaceRoot: normalizedRoot,
73
+ workspaceLabel: slugSegments[slugSegments.length - 1],
74
+ workspaceSlug: slugSegments.join('/'),
75
+ };
76
+ }
77
+
78
+ if (!workspaceParam || typeof workspaceParam !== 'string') {
79
+ throw createHttpError(400, 'Missing workspace');
80
+ }
81
+
82
+ const segments = sanitizeSegments(workspaceParam);
83
+ if (segments.length === 0) {
84
+ throw createHttpError(400, 'Workspace path is required');
85
+ }
86
+
87
+ const workspaceRoot = path.resolve(apiRoot, ...segments);
88
+ const relativeToApi = path.relative(apiRoot, workspaceRoot);
89
+ if (relativeToApi.startsWith('..') || path.isAbsolute(relativeToApi)) {
90
+ throw createHttpError(400, 'Workspace outside api directory');
91
+ }
92
+ const existsResult = await exists(workspaceRoot);
93
+ if (!existsResult) {
94
+ throw createHttpError(404, 'Workspace not found');
95
+ }
96
+ return {
97
+ apiRoot,
98
+ segments,
99
+ workspaceRoot,
100
+ workspaceLabel: segments[segments.length - 1],
101
+ workspaceSlug: segments.join('/'),
102
+ };
103
+ };
104
+
105
+ const resolveWorkspacePath = async (workspaceParam, relativeParam = '', rootParam) => {
106
+ const workspaceInfo = await ensureWorkspace(workspaceParam, rootParam);
107
+ const relativeSegments = sanitizeSegments(relativeParam);
108
+ const absolutePath = path.resolve(workspaceInfo.workspaceRoot, ...relativeSegments);
109
+ const relativeToWorkspace = path.relative(workspaceInfo.workspaceRoot, absolutePath);
110
+ if (relativeToWorkspace.startsWith('..') || path.isAbsolute(relativeToWorkspace)) {
111
+ throw createHttpError(400, 'Path escapes workspace');
112
+ }
113
+ const relativePosix = relativeSegments.join('/');
114
+ return {
115
+ ...workspaceInfo,
116
+ absolutePath,
117
+ relativeSegments,
118
+ relativePosix,
119
+ };
120
+ };
121
+
122
+ router.get('/pinokio/fileview/*', asyncHandler(async (req, res) => {
123
+ const workspaceParam = req.params[0] || '';
124
+ const initialRelative = req.query.path || '';
125
+ const { workspaceRoot, workspaceLabel, workspaceSlug, relativePosix, absolutePath } = await resolveWorkspacePath(workspaceParam, initialRelative);
126
+ const initialPosixPath = relativePosix;
127
+ const initialStats = await fs.promises.stat(absolutePath).catch(() => null);
128
+ const initialType = initialStats ? (initialStats.isDirectory() ? 'directory' : initialStats.isFile() ? 'file' : null) : null;
129
+
130
+ const workspaceMeta = {
131
+ title: '',
132
+ description: '',
133
+ icon: '',
134
+ iconpath: ''
135
+ };
136
+ try {
137
+ const meta = await kernel.api.meta(workspaceSlug);
138
+ if (meta && typeof meta === 'object') {
139
+ workspaceMeta.title = typeof meta.title === 'string' ? meta.title : '';
140
+ workspaceMeta.description = typeof meta.description === 'string' ? meta.description : '';
141
+ workspaceMeta.icon = typeof meta.icon === 'string' ? meta.icon : '';
142
+ workspaceMeta.iconpath = typeof meta.iconpath === 'string' ? meta.iconpath : '';
143
+ }
144
+ } catch (error) {
145
+ // Metadata is optional for the file editor; ignore failures.
146
+ }
147
+
148
+ const workspaceRootEncoded = Buffer.from(workspaceRoot).toString('base64');
149
+ res.render('file_browser', {
150
+ theme: getTheme ? getTheme() : 'light',
151
+ agent: req.agent,
152
+ workspace: workspaceSlug,
153
+ workspaceLabel,
154
+ workspaceRoot,
155
+ workspaceSlug,
156
+ workspaceRootEncoded,
157
+ initialPath: initialPosixPath,
158
+ initialPathType: initialType,
159
+ workspaceMeta
160
+ });
161
+ }));
162
+
163
+ router.get('/api/files/list', asyncHandler(async (req, res) => {
164
+ const { workspace, path: relativeQuery } = req.query;
165
+ const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativeQuery, req.query.root);
166
+
167
+ const stats = await fs.promises.stat(absolutePath).catch(() => null);
168
+ if (!stats) {
169
+ throw createHttpError(404, 'Directory not found');
170
+ }
171
+ if (!stats.isDirectory()) {
172
+ throw createHttpError(400, 'Path must be a directory');
173
+ }
174
+
175
+ const entries = await fs.promises.readdir(absolutePath, { withFileTypes: true }).catch(() => []);
176
+ const sorted = entries.sort((a, b) => {
177
+ if (a.isDirectory() && !b.isDirectory()) return -1;
178
+ if (!a.isDirectory() && b.isDirectory()) return 1;
179
+ return a.name.localeCompare(b.name);
180
+ });
181
+
182
+ const items = await Promise.all(sorted.map(async (dirent) => {
183
+ const entrySegments = [...(relativePosix ? relativePosix.split('/') : []), dirent.name];
184
+ const entryPosix = entrySegments.filter(Boolean).join('/');
185
+ let hasChildren = false;
186
+ if (dirent.isDirectory()) {
187
+ const candidatePath = path.resolve(absolutePath, dirent.name);
188
+ try {
189
+ const childDir = await fs.promises.opendir(candidatePath);
190
+ let count = 0;
191
+ for await (const child of childDir) {
192
+ count += 1;
193
+ if (count > 0) {
194
+ hasChildren = true;
195
+ break;
196
+ }
197
+ }
198
+ await childDir.close();
199
+ } catch (err) {
200
+ hasChildren = false;
201
+ }
202
+ }
203
+ return {
204
+ name: dirent.name,
205
+ path: entryPosix,
206
+ type: dirent.isDirectory() ? 'directory' : 'file',
207
+ workspace: workspaceSlug,
208
+ hasChildren,
209
+ };
210
+ }));
211
+
212
+ res.json({
213
+ workspace: workspaceSlug,
214
+ path: relativePosix,
215
+ entries: items,
216
+ });
217
+ }));
218
+
219
+ router.get('/api/files/read', asyncHandler(async (req, res) => {
220
+ const { workspace, path: relativeQuery } = req.query;
221
+ const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativeQuery, req.query.root);
222
+
223
+ const stats = await fs.promises.stat(absolutePath).catch(() => null);
224
+ if (!stats) {
225
+ throw createHttpError(404, 'File not found');
226
+ }
227
+ if (!stats.isFile()) {
228
+ throw createHttpError(400, 'Path must be a file');
229
+ }
230
+ const metaOnly = Object.prototype.hasOwnProperty.call(req.query, 'meta');
231
+ if (metaOnly) {
232
+ res.json({
233
+ workspace: workspaceSlug,
234
+ path: relativePosix,
235
+ size: stats.size,
236
+ mtime: stats.mtimeMs,
237
+ meta: true,
238
+ });
239
+ return;
240
+ }
241
+ if (stats.size > MAX_EDITABLE_FILE_SIZE_BYTES) {
242
+ throw createHttpError(413, 'File is too large to open in the editor');
243
+ }
244
+ const isBinary = await isBinaryFile(absolutePath);
245
+ if (isBinary) {
246
+ throw createHttpError(415, 'Binary files cannot be opened in the editor');
247
+ }
248
+ const content = await fs.promises.readFile(absolutePath, 'utf8');
249
+ res.json({
250
+ workspace: workspaceSlug,
251
+ path: relativePosix,
252
+ content,
253
+ size: stats.size,
254
+ mtime: stats.mtimeMs,
255
+ });
256
+ }));
257
+
258
+ router.post('/api/files/save', asyncHandler(async (req, res) => {
259
+ const { workspace, path: relativePath, content, root: rootParam } = req.body || {};
260
+ if (typeof content !== 'string') {
261
+ throw createHttpError(400, 'File content must be a string');
262
+ }
263
+
264
+ const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativePath, rootParam);
265
+ const stats = await fs.promises.stat(absolutePath).catch(() => null);
266
+ if (!stats) {
267
+ throw createHttpError(404, 'File not found');
268
+ }
269
+ if (!stats.isFile()) {
270
+ throw createHttpError(400, 'Path must be a file');
271
+ }
272
+
273
+ await fs.promises.writeFile(absolutePath, content, 'utf8');
274
+ const updatedStats = await fs.promises.stat(absolutePath);
275
+ res.json({
276
+ workspace: workspaceSlug,
277
+ path: relativePosix,
278
+ size: updatedStats.size,
279
+ mtime: updatedStats.mtimeMs,
280
+ });
281
+ }));
282
+
283
+ app.use(router);
284
+ };
@@ -22,6 +22,9 @@ body.dark #devtab.selected {
22
22
  border: none;
23
23
  background: rgba(255,255,255,0.07);
24
24
  }
25
+ #editortab {
26
+ color: #7f5bf3;
27
+ }
25
28
  #devtab {
26
29
  align-items: center;
27
30
  justify-content: center;
@@ -393,10 +396,36 @@ body.dark .appcanvas > aside .header-item.btn:not(.selected) {
393
396
  .appcanvas > aside .header-item .tab {
394
397
  display: flex;
395
398
  align-items: center;
399
+ /*
396
400
  gap: 6px;
401
+ */
397
402
  flex: 1;
398
403
  min-width: 0;
399
404
  }
405
+ .appcanvas > aside .header-item .tab .tab-metric {
406
+ display: inline-flex;
407
+ align-items: baseline;
408
+ gap: 6px;
409
+ font-size: 11px;
410
+ white-space: nowrap;
411
+ flex: 0 0 auto;
412
+ padding-left: 6px;
413
+ }
414
+ .appcanvas > aside .header-item .tab .tab-metric__label {
415
+ text-transform: uppercase;
416
+ letter-spacing: 0.06em;
417
+ font-size: 10px;
418
+ opacity: 0.7;
419
+ }
420
+ .appcanvas > aside .header-item .tab .tab-metric__value {
421
+ font-weight: 600;
422
+ font-size: 11px;
423
+ }
424
+ .appcanvas > aside .header-item .tab .tab-metric .disk-usage {
425
+ flex: 0 0 auto;
426
+ min-width: 0;
427
+ text-align: right;
428
+ }
400
429
 
401
430
  .appcanvas > aside .header-item:hover {
402
431
  background: var(--pinokio-sidebar-tab-hover);
@@ -1261,6 +1290,7 @@ body.dark .submenu {
1261
1290
  flex-grow: 1;
1262
1291
  font-weight: bold;
1263
1292
  text-align: right;
1293
+ opacity: 0.5;
1264
1294
  }
1265
1295
  .disk-usage i {
1266
1296
  margin-right: 5px;
@@ -1426,8 +1456,8 @@ body.dark #fs-status {
1426
1456
 
1427
1457
  .fs-status-dropdown.git-fork .fs-dropdown-menu,
1428
1458
  .fs-status-dropdown.git-publish .fs-dropdown-menu {
1429
- left: auto;
1430
- right: 0;
1459
+ left: 0;
1460
+ right: auto;
1431
1461
  width: min(420px, calc(100vw - 24px));
1432
1462
  max-width: min(420px, calc(100vw - 24px));
1433
1463
  white-space: normal;
@@ -1504,9 +1534,6 @@ body.dark #fs-status .fs-dropdown-menu .frame-link:hover {
1504
1534
  background: rgba(148, 163, 184, 0.15);
1505
1535
  }
1506
1536
 
1507
- #fs-status .git-changes {
1508
- margin-left: auto;
1509
- }
1510
1537
 
1511
1538
  #fs-changes-menu .fs-dropdown-empty {
1512
1539
  padding: 8px 14px;
@@ -1515,8 +1542,8 @@ body.dark #fs-status .fs-dropdown-menu .frame-link:hover {
1515
1542
  }
1516
1543
 
1517
1544
  #fs-changes-menu {
1518
- left: auto;
1519
- right: 0;
1545
+ left: 0;
1546
+ right: auto;
1520
1547
  width: max-content;
1521
1548
  max-width: min(460px, calc(100vw - 32px));
1522
1549
  max-height: min(70vh, 420px);
@@ -2900,6 +2927,15 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2900
2927
  <div class='loader'><i class='fa-solid fa-angle-right'></i></div>
2901
2928
  -->
2902
2929
  </a>
2930
+ <a id='editortab' data-mode="refresh" target="<%=editor_tab%>" href="<%=editor_tab%>" class="btn header-item frame-link edit-tab" data-index="11">
2931
+ <div class='tab'>
2932
+ <i class="fa-solid fa-file-lines"></i>
2933
+ <div class='display'>Files</div>
2934
+ <div class='tab-metric'>
2935
+ <span class='disk-usage tab-metric__value' data-path="/">--</span>
2936
+ </div>
2937
+ </div>
2938
+ </a>
2903
2939
  <div class="dynamic <%=type==='run' ? '' : 'selected'%>">
2904
2940
  <div class='submenu'>
2905
2941
  <% if (plugin_menu) { %>
@@ -2914,6 +2950,13 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2914
2950
  <% if (config.menu) { %>
2915
2951
  <%- include('./partials/menu', { menu: config.menu, }) %>
2916
2952
  <% } %>
2953
+ <a id='settings-tab' data-mode="refresh" target="env-settings" href="/env/api/<%=name%>" class="btn header-item frame-link" data-index="settings" data-static="retain">
2954
+ <div class='tab'>
2955
+ <i class="fa-solid fa-gear"></i>
2956
+ <div class='display'>Settings</div>
2957
+ <div class='flexible'></div>
2958
+ </div>
2959
+ </a>
2917
2960
  </div>
2918
2961
  <% } %>
2919
2962
  <div class='m s temp-menu' data-type='s'>
@@ -2933,27 +2976,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2933
2976
  <div class='container'>
2934
2977
  <% if (type === "browse") { %>
2935
2978
  <div id='fs-status' data-workspace="<%=name%>" data-create-uri="<%=git_create_url%>" data-history-uri="<%=git_history_url%>" data-status-uri="<%=git_status_url%>" data-uri="<%=git_monitor_url%>" data-push-uri="<%=git_push_url%>" data-fork-uri="<%=git_fork_url%>">
2936
- <a target="<%=src%>" href="<%=src%>" class='fs-status-btn frame-link' data-index="0" data-mode="refresh" data-type="n">
2937
- <span class='fs-status-label'>
2938
- <i class="fa-regular fa-folder-open"></i>
2939
- Files |
2940
- <div class='disk-usage' data-path="/"></div>
2941
- </span>
2942
- </a>
2943
- <div class='fs-status-dropdown'>
2944
- <button id='edit-toggle' class='fs-status-btn revealer' data-group='#fs-edit-menu' type='button'>
2945
- <span class='fs-status-label'>
2946
- <i class='fa-solid fa-pen-to-square'></i>
2947
- Edit
2948
- </span>
2949
- </button>
2950
- <div class='fs-dropdown-menu submenu hidden' id='fs-edit-menu'>
2951
- <button type='button' class='fs-dropdown-item' id='edit'><i class="fa-solid fa-pencil"></i> Edit</button>
2952
- <button type='button' class='fs-dropdown-item' id='move'><i class="fa-solid fa-right-to-bracket"></i> Move</button>
2953
- <button type='button' class='fs-dropdown-item' id='copy'><i class="fa-solid fa-copy"></i> Copy</button>
2954
- <button type='button' class='fs-dropdown-item' id='delete' data-name="<%=name%>"><i class="fa-solid fa-trash-can"></i> Delete</button>
2955
- </div>
2956
- </div>
2957
2979
  <!--
2958
2980
  <div class='fs-status-dropdown nested-menu git blue'>
2959
2981
  <button type='button' class='fs-status-btn frame-link reveal'>
@@ -2966,11 +2988,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2966
2988
  </div>
2967
2989
  </div>
2968
2990
  -->
2969
- <div class='fs-status-dropdown'>
2970
- <a target="/env/api/<%=name%>" href="/env/api/<%=name%>" class='fs-status-btn frame-link selected' data-index="3" data-mode="refresh" data-type="n">
2971
- <span class='fs-status-label'><i class='fa-solid fa-gear'></i> Settings</span>
2972
- </a>
2973
- </div>
2974
2991
  <div class='fs-status-dropdown git-changes'>
2975
2992
  <button id='fs-changes-btn' class='fs-status-btn revealer' data-group='#fs-changes-menu' type='button'>
2976
2993
  <span class='fs-status-label'><i class="fa-solid fa-code-compare"></i> Changes</span>
@@ -4842,6 +4859,9 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4842
4859
  if (!targetContainer) {
4843
4860
  return
4844
4861
  }
4862
+ const staticNodes = Array.from(targetContainer.children || []).filter((node) => {
4863
+ return node && node.dataset && node.dataset.static === 'retain'
4864
+ })
4845
4865
  const template = document.createElement('template')
4846
4866
  template.innerHTML = html || ''
4847
4867
  const fragment = template.content
@@ -4857,6 +4877,28 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4857
4877
  }
4858
4878
  const nodes = Array.from(fragment.childNodes)
4859
4879
  targetContainer.replaceChildren(...nodes)
4880
+ if (staticNodes.length > 0) {
4881
+ staticNodes.forEach((node) => {
4882
+ if (!node) {
4883
+ return
4884
+ }
4885
+ if (node.parentElement === targetContainer) {
4886
+ return
4887
+ }
4888
+ if (node.dataset && node.dataset.static) {
4889
+ const duplicates = Array.from(targetContainer.children || []).filter((candidate) => {
4890
+ if (candidate === node) {
4891
+ return false
4892
+ }
4893
+ return candidate.dataset && candidate.dataset.static === node.dataset.static
4894
+ })
4895
+ duplicates.forEach((duplicate) => {
4896
+ duplicate.remove()
4897
+ })
4898
+ }
4899
+ targetContainer.appendChild(node)
4900
+ })
4901
+ }
4860
4902
  }
4861
4903
  let timestampIntervalId = null
4862
4904
  const ensureTimestampInterval = () => {
@@ -0,0 +1,130 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
6
+ <title>Files · <%=workspaceLabel%></title>
7
+ <link href="/css/fontawesome.min.css" rel="stylesheet">
8
+ <link href="/css/solid.min.css" rel="stylesheet">
9
+ <link href="/css/regular.min.css" rel="stylesheet">
10
+ <link href="/css/brands.min.css" rel="stylesheet">
11
+ <link href="/style.css" rel="stylesheet">
12
+ <link href="/files-app/app.css" rel="stylesheet">
13
+ <link href="/filepond.min.css" rel="stylesheet" />
14
+ <link href="/filepond-plugin-image-preview.min.css" rel="stylesheet" />
15
+ <link href="/filepond-plugin-image-edit.min.css" rel="stylesheet" />
16
+ <link href="/noty.css" rel="stylesheet">
17
+ <% if (agent === 'electron') { %>
18
+ <link href="/electron.css" rel="stylesheet">
19
+ <% } %>
20
+ </head>
21
+ <body class="<%=theme%>" data-agent="<%=agent%>">
22
+ <div class="files-app" data-theme="<%=theme%>">
23
+ <header class="files-app__header">
24
+ <div class="files-app__header-title">
25
+ <i class="fa-regular fa-folder-open"></i>
26
+ <div class="files-app__header-text">
27
+ <div class="files-app__header-primary"><%=workspaceLabel%></div>
28
+ <div class="files-app__header-sub"><%=workspaceRoot%></div>
29
+ </div>
30
+ </div>
31
+ <div class="files-app__header-actions">
32
+ <button class="files-app__meta" type="button" id="files-app-edit-metadata">
33
+ <i class="fa-solid fa-pen-to-square"></i>
34
+ <span>Edit metadata</span>
35
+ </button>
36
+ <button class="files-app__save" id="files-app-save" disabled>
37
+ <i class="fa-solid fa-floppy-disk"></i>
38
+ <span>Save</span>
39
+ </button>
40
+ </div>
41
+ </header>
42
+ <section class="files-app__body">
43
+ <aside class="files-app__sidebar" aria-label="Project files">
44
+ <div class="files-app__sidebar-scroll" id="files-app-tree">
45
+ </div>
46
+ </aside>
47
+ <main class="files-app__main">
48
+ <div class="files-app__tabs" id="files-app-tabs" role="tablist" aria-label="Open files"></div>
49
+ <div class="files-app__editor" id="files-app-editor" role="presentation"></div>
50
+ <div class="files-app__status" id="files-app-status" role="status" aria-live="polite"></div>
51
+ </main>
52
+ </section>
53
+ </div>
54
+ <script src="/sweetalert2.js"></script>
55
+ <script src="/filepond-plugin-file-validate-type.min.js"></script>
56
+ <script src="/filepond-plugin-image-exif-orientation.min.js"></script>
57
+ <script src="/filepond-plugin-image-preview.min.js"></script>
58
+ <script src="/filepond-plugin-image-edit.min.js"></script>
59
+ <script src="/filepond-plugin-image-crop.min.js"></script>
60
+ <script src="/filepond-plugin-image-resize.min.js"></script>
61
+ <script src="/filepond-plugin-image-transform.min.js"></script>
62
+ <script src="/filepond.min.js"></script>
63
+ <script src="/fseditor.js"></script>
64
+ <script src="/ace/ace.js"></script>
65
+ <script src="/ace/ext-modelist.js"></script>
66
+ <script src="/files-app/app.js"></script>
67
+ <script>
68
+ window.FilesApp.init({
69
+ workspace: <%- JSON.stringify(workspaceSlug) %>,
70
+ workspaceLabel: <%- JSON.stringify(workspaceLabel) %>,
71
+ theme: <%- JSON.stringify(theme) %>,
72
+ initialPath: <%- JSON.stringify(initialPath || '') %>,
73
+ initialPathType: <%- JSON.stringify(initialPathType || null) %>,
74
+ workspaceRoot: <%- JSON.stringify(workspaceRootEncoded || '') %>
75
+ });
76
+
77
+ const workspaceMetadata = {
78
+ title: <%- JSON.stringify((workspaceMeta && workspaceMeta.title) || '') %>,
79
+ description: <%- JSON.stringify((workspaceMeta && workspaceMeta.description) || '') %>,
80
+ icon: <%- JSON.stringify((workspaceMeta && workspaceMeta.icon) || '') %>,
81
+ iconpath: <%- JSON.stringify((workspaceMeta && workspaceMeta.iconpath) || '') %>
82
+ };
83
+ const workspaceSlugValue = <%- JSON.stringify(workspaceSlug) %>;
84
+
85
+ const encodeWorkspacePath = (value) => {
86
+ if (!value) {
87
+ return '';
88
+ }
89
+ return value
90
+ .split('/')
91
+ .filter(Boolean)
92
+ .map((segment) => encodeURIComponent(segment))
93
+ .join('/');
94
+ };
95
+
96
+ const redirectToWorkspace = (newPath) => {
97
+ if (!newPath) {
98
+ return window.location.href;
99
+ }
100
+ const encoded = encodeWorkspacePath(newPath);
101
+ const url = new URL(window.location.href);
102
+ url.pathname = `/pinokio/fileview/${encoded}`;
103
+ return url.toString();
104
+ };
105
+
106
+ const metadataButton = document.getElementById('files-app-edit-metadata');
107
+ if (metadataButton) {
108
+ metadataButton.addEventListener('click', async (event) => {
109
+ event.preventDefault();
110
+ if (typeof FSEditor !== 'function') {
111
+ return;
112
+ }
113
+ try {
114
+ await FSEditor({
115
+ title: workspaceMetadata.title,
116
+ description: workspaceMetadata.description,
117
+ old_path: workspaceSlugValue,
118
+ icon: workspaceMetadata.icon,
119
+ iconpath: workspaceMetadata.iconpath,
120
+ edit: true,
121
+ redirect: redirectToWorkspace
122
+ });
123
+ } catch (error) {
124
+ console.error('Failed to open metadata editor', error);
125
+ }
126
+ });
127
+ }
128
+ </script>
129
+ </body>
130
+ </html>
@@ -704,10 +704,9 @@ document.addEventListener('DOMContentLoaded', function() {
704
704
  <% item.external_router.forEach((domain) => { %>
705
705
  <a class='net' target="_blank" href="https://<%=domain%>"><span class='mark'>HTTPS</span><span><%=domain%></span></a>
706
706
  <% }) %>
707
- <% } else { %>
708
- <% if (item.external_ip) { %>
709
- <a class='net' target="_blank" href="http://<%=item.external_ip%>"><span class='mark'>HTTP</span><span><%=item.external_ip%></span></a>
710
- <% } %>
707
+ <% } %>
708
+ <% if (item.external_ip) { %>
709
+ <a class='net' target="_blank" href="http://<%=item.external_ip%>"><span class='mark'>HTTP</span><span><%=item.external_ip%></span></a>
711
710
  <% } %>
712
711
  </div>
713
712
  </div>