@jxsuite/studio 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/studio.js +2072 -1588
- package/dist/studio.js.map +30 -21
- package/package.json +2 -2
- package/src/browse/browse.js +24 -3
- package/src/canvas/canvas-render.js +2 -2
- package/src/canvas/canvas-utils.js +1 -1
- package/src/editor/shortcuts.js +7 -0
- package/src/files/files.js +17 -2
- package/src/panels/activity-bar.js +12 -0
- package/src/panels/left-panel.js +2 -2
- package/src/panels/overlays.js +2 -2
- package/src/panels/quick-search.js +170 -0
- package/src/panels/style-panel.js +1 -1
- package/src/panels/stylebook-panel.js +30 -529
- package/src/panels/toolbar.js +69 -15
- package/src/platforms/devserver.js +12 -0
- package/src/recent-projects.js +57 -0
- package/src/settings/css-vars-editor.js +307 -0
- package/src/settings/general-settings.js +94 -0
- package/src/settings/head-editor.js +184 -0
- package/src/settings/settings-modal.js +117 -0
- package/src/studio.js +58 -9
- package/src/ui/spectrum.js +2 -0
package/src/panels/toolbar.js
CHANGED
|
@@ -11,6 +11,8 @@ import { activeTab } from "../workspace/workspace.js";
|
|
|
11
11
|
import { getEffectiveMedia } from "../site-context.js";
|
|
12
12
|
import { mediaDisplayName } from "./shared.js";
|
|
13
13
|
import { view } from "../view.js";
|
|
14
|
+
import { getRecentProjects } from "../recent-projects.js";
|
|
15
|
+
import { openQuickSearch } from "./quick-search.js";
|
|
14
16
|
|
|
15
17
|
/** @type {HTMLElement | null} */
|
|
16
18
|
let _rootEl = null;
|
|
@@ -62,6 +64,9 @@ function tbBtnTpl(label, onClick, iconTag) {
|
|
|
62
64
|
export function mount(rootEl, ctx) {
|
|
63
65
|
_rootEl = rootEl;
|
|
64
66
|
_ctx = ctx;
|
|
67
|
+
if (/** @type {any} */ (globalThis).__jxPlatform?.windowControls) {
|
|
68
|
+
rootEl.classList.add("electrobun-webkit-app-region-drag");
|
|
69
|
+
}
|
|
65
70
|
_scope = effectScope();
|
|
66
71
|
_scope.run(() => {
|
|
67
72
|
effect(() => {
|
|
@@ -189,9 +194,12 @@ function toolbarTemplate() {
|
|
|
189
194
|
{ key: "design", label: "Design", iconTag: "sp-icon-artboard" },
|
|
190
195
|
{ key: "preview", label: "Preview", iconTag: "sp-icon-preview" },
|
|
191
196
|
{ key: "source", label: "Code", iconTag: "sp-icon-code" },
|
|
192
|
-
{ key: "
|
|
197
|
+
{ key: "stylebook", label: "Stylebook", iconTag: "sp-icon-brush" },
|
|
193
198
|
];
|
|
194
199
|
|
|
200
|
+
const isProjectFile = S.documentPath === "project.json";
|
|
201
|
+
const allowedModes = isProjectFile ? new Set(["stylebook", "source"]) : null;
|
|
202
|
+
|
|
195
203
|
const modeSwitcherTpl = html`
|
|
196
204
|
<sp-action-group selects="single" size="s" compact>
|
|
197
205
|
${modes.map(
|
|
@@ -199,8 +207,10 @@ function toolbarTemplate() {
|
|
|
199
207
|
<sp-action-button
|
|
200
208
|
size="s"
|
|
201
209
|
?selected=${canvasMode === m.key}
|
|
210
|
+
?disabled=${allowedModes && !allowedModes.has(m.key)}
|
|
202
211
|
@click=${() => {
|
|
203
212
|
if (canvasMode === m.key) return;
|
|
213
|
+
if (allowedModes && !allowedModes.has(m.key)) return;
|
|
204
214
|
if (S.ui.editingFunction) {
|
|
205
215
|
if (view.functionEditor) {
|
|
206
216
|
view.functionEditor.dispose();
|
|
@@ -212,7 +222,7 @@ function toolbarTemplate() {
|
|
|
212
222
|
view.panY = 0;
|
|
213
223
|
/** @type {Record<string, any>} */
|
|
214
224
|
const uiPatch = { editingFunction: null };
|
|
215
|
-
if (m.key === "
|
|
225
|
+
if (m.key === "stylebook") uiPatch.rightTab = "style";
|
|
216
226
|
if (m.key === "manage") view.leftTab = "files";
|
|
217
227
|
updateSession({ ui: uiPatch });
|
|
218
228
|
_ctx.renderCanvas();
|
|
@@ -226,10 +236,60 @@ function toolbarTemplate() {
|
|
|
226
236
|
</sp-action-group>
|
|
227
237
|
`;
|
|
228
238
|
|
|
239
|
+
const windowControls = /** @type {any} */ (globalThis).__jxPlatform?.windowControls;
|
|
240
|
+
const csdTpl = windowControls
|
|
241
|
+
? html`
|
|
242
|
+
<sp-action-group class="window-controls" size="s">
|
|
243
|
+
<sp-action-button
|
|
244
|
+
quiet
|
|
245
|
+
size="s"
|
|
246
|
+
title="Minimize"
|
|
247
|
+
@click=${() => windowControls.minimize()}
|
|
248
|
+
>
|
|
249
|
+
<sp-icon-remove slot="icon"></sp-icon-remove>
|
|
250
|
+
</sp-action-button>
|
|
251
|
+
<sp-action-button
|
|
252
|
+
quiet
|
|
253
|
+
size="s"
|
|
254
|
+
title="Maximize"
|
|
255
|
+
@click=${() => windowControls.maximize()}
|
|
256
|
+
>
|
|
257
|
+
<sp-icon-full-screen slot="icon"></sp-icon-full-screen>
|
|
258
|
+
</sp-action-button>
|
|
259
|
+
<sp-action-button
|
|
260
|
+
quiet
|
|
261
|
+
size="s"
|
|
262
|
+
title="Close"
|
|
263
|
+
class="csd-close"
|
|
264
|
+
@click=${() => windowControls.close()}
|
|
265
|
+
>
|
|
266
|
+
<sp-icon-close slot="icon"></sp-icon-close>
|
|
267
|
+
</sp-action-button>
|
|
268
|
+
</sp-action-group>
|
|
269
|
+
`
|
|
270
|
+
: nothing;
|
|
271
|
+
|
|
272
|
+
const recentProjects = getRecentProjects();
|
|
273
|
+
const recentProjectsTpl = recentProjects.length
|
|
274
|
+
? html`
|
|
275
|
+
<overlay-trigger placement="bottom-start">
|
|
276
|
+
<sp-action-button size="s" slot="trigger" title="Recent projects">
|
|
277
|
+
<sp-icon-chevron-down slot="icon"></sp-icon-chevron-down>
|
|
278
|
+
</sp-action-button>
|
|
279
|
+
<sp-popover slot="click-content" tip>
|
|
280
|
+
<sp-menu @change=${(/** @type {any} */ e) => _ctx.openRecentProject(e.target.value)}>
|
|
281
|
+
${recentProjects.map(
|
|
282
|
+
(p) => html`<sp-menu-item value=${p.root}>${p.name}</sp-menu-item>`,
|
|
283
|
+
)}
|
|
284
|
+
</sp-menu>
|
|
285
|
+
</sp-popover>
|
|
286
|
+
</overlay-trigger>
|
|
287
|
+
`
|
|
288
|
+
: nothing;
|
|
289
|
+
|
|
229
290
|
return html`
|
|
230
291
|
<sp-action-group compact size="s">
|
|
231
|
-
${tbBtnTpl("Open Project", _ctx.openProject, "sp-icon-folder-open")}
|
|
232
|
-
${tbBtnTpl("Open File", _ctx.openFile, "sp-icon-document")}
|
|
292
|
+
${tbBtnTpl("Open Project", _ctx.openProject, "sp-icon-folder-open")} ${recentProjectsTpl}
|
|
233
293
|
${tbBtnTpl("Save", _ctx.saveFile, "sp-icon-save-floppy")}
|
|
234
294
|
</sp-action-group>
|
|
235
295
|
<sp-action-group compact size="s">
|
|
@@ -237,17 +297,11 @@ function toolbarTemplate() {
|
|
|
237
297
|
${tbBtnTpl("Redo", () => tabRedo(activeTab.value), "sp-icon-redo")}
|
|
238
298
|
</sp-action-group>
|
|
239
299
|
<div class="tb-spacer"></div>
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
: S.fileHandle
|
|
245
|
-
? html`<span class="tb-file-title"
|
|
246
|
-
>${S.fileHandle.name}${S.dirty ? html`<span class="tb-dirty">●</span>` : nothing}</span
|
|
247
|
-
>`
|
|
248
|
-
: nothing}
|
|
249
|
-
${breadcrumbTpl}
|
|
300
|
+
<sp-action-button class="tb-search-trigger" size="s" quiet @click=${openQuickSearch}>
|
|
301
|
+
<sp-icon-search slot="icon"></sp-icon-search>
|
|
302
|
+
<span class="tb-search-label">Search files… <kbd>⌘P</kbd></span>
|
|
303
|
+
</sp-action-button>
|
|
250
304
|
<div class="tb-spacer"></div>
|
|
251
|
-
${togglesTpl} ${modeSwitcherTpl}
|
|
305
|
+
${breadcrumbTpl} ${togglesTpl} ${modeSwitcherTpl} ${csdTpl}
|
|
252
306
|
`;
|
|
253
307
|
}
|
|
@@ -315,6 +315,18 @@ export function createDevServerPlatform() {
|
|
|
315
315
|
return null;
|
|
316
316
|
},
|
|
317
317
|
|
|
318
|
+
/** @param {string} query */
|
|
319
|
+
async searchFiles(query) {
|
|
320
|
+
const glob = `**/*${query}*.{json,md}`;
|
|
321
|
+
const res = await fetch(
|
|
322
|
+
`/__studio/files?dir=${encodeURIComponent(serverPath("."))}&glob=${encodeURIComponent(glob)}`,
|
|
323
|
+
);
|
|
324
|
+
if (!res.ok) return [];
|
|
325
|
+
const entries = await res.json();
|
|
326
|
+
for (const e of entries) e.path = stripRoot(e.path);
|
|
327
|
+
return entries;
|
|
328
|
+
},
|
|
329
|
+
|
|
318
330
|
// ─── Plugin schema ────────────────────────────────────────────────────
|
|
319
331
|
|
|
320
332
|
/**
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const STORAGE_KEY = "jx-studio-recent-projects";
|
|
2
|
+
const FILES_STORAGE_KEY = "jx-studio-recent-files";
|
|
3
|
+
const MAX_RECENT = 8;
|
|
4
|
+
const MAX_RECENT_FILES = 10;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{ name: string; root: string; timestamp: number }} RecentProject
|
|
8
|
+
*
|
|
9
|
+
* @typedef {{ path: string; name: string; timestamp: number }} RecentFile
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @returns {RecentProject[]} */
|
|
13
|
+
export function getRecentProjects() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
16
|
+
if (!raw) return [];
|
|
17
|
+
return /** @type {RecentProject[]} */ (JSON.parse(raw)).sort(
|
|
18
|
+
(a, b) => b.timestamp - a.timestamp,
|
|
19
|
+
);
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} name
|
|
27
|
+
* @param {string} root
|
|
28
|
+
*/
|
|
29
|
+
export function addRecentProject(name, root) {
|
|
30
|
+
const projects = getRecentProjects().filter((p) => p.root !== root);
|
|
31
|
+
projects.unshift({ name, root, timestamp: Date.now() });
|
|
32
|
+
if (projects.length > MAX_RECENT) projects.length = MAX_RECENT;
|
|
33
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clearRecentProjects() {
|
|
37
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns {RecentFile[]} */
|
|
41
|
+
export function getRecentFiles() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = localStorage.getItem(FILES_STORAGE_KEY);
|
|
44
|
+
if (!raw) return [];
|
|
45
|
+
return /** @type {RecentFile[]} */ (JSON.parse(raw)).sort((a, b) => b.timestamp - a.timestamp);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {{ path: string; name: string }} file */
|
|
52
|
+
export function trackRecentFile(file) {
|
|
53
|
+
const recent = getRecentFiles().filter((f) => f.path !== file.path);
|
|
54
|
+
recent.unshift({ path: file.path, name: file.name, timestamp: Date.now() });
|
|
55
|
+
if (recent.length > MAX_RECENT_FILES) recent.length = MAX_RECENT_FILES;
|
|
56
|
+
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(recent));
|
|
57
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Variables editor — form-based editor for managing design tokens. Colors and fonts are NOT
|
|
3
|
+
* media-aware; sizes/spacing are media-aware.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
7
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
8
|
+
import { projectState } from "../store.js";
|
|
9
|
+
import { updateSiteConfig } from "../site-context.js";
|
|
10
|
+
import { getEffectiveMedia } from "../site-context.js";
|
|
11
|
+
import { friendlyNameToVar, varDisplayName } from "../utils/studio-utils.js";
|
|
12
|
+
|
|
13
|
+
/** @param {HTMLElement} container */
|
|
14
|
+
export function renderCssVarsEditor(container) {
|
|
15
|
+
const config = projectState.projectConfig || {};
|
|
16
|
+
const rootStyle = config.style || {};
|
|
17
|
+
const media = getEffectiveMedia(config.$media);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @type {{
|
|
21
|
+
* color: [string, any][];
|
|
22
|
+
* font: [string, any][];
|
|
23
|
+
* size: [string, any][];
|
|
24
|
+
* other: [string, any][];
|
|
25
|
+
* }}
|
|
26
|
+
*/
|
|
27
|
+
const groups = { color: [], font: [], size: [], other: [] };
|
|
28
|
+
for (const [k, v] of Object.entries(rootStyle)) {
|
|
29
|
+
if (!k.startsWith("--")) continue;
|
|
30
|
+
if (typeof v !== "string" && typeof v !== "number") continue;
|
|
31
|
+
if (k.startsWith("--color")) groups.color.push([k, v]);
|
|
32
|
+
else if (k.startsWith("--font")) groups.font.push([k, v]);
|
|
33
|
+
else if (k.startsWith("--size") || k.startsWith("--spacing") || k.startsWith("--radius"))
|
|
34
|
+
groups.size.push([k, v]);
|
|
35
|
+
else groups.other.push([k, v]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mediaNames = media ? Object.keys(media).filter((m) => m !== "--") : [];
|
|
39
|
+
|
|
40
|
+
const save = () => {
|
|
41
|
+
updateSiteConfig({ style: { ...rootStyle } });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const updateVar = (/** @type {string} */ name, /** @type {string} */ val) => {
|
|
45
|
+
rootStyle[name] = val;
|
|
46
|
+
save();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const deleteVar = (/** @type {string} */ name) => {
|
|
50
|
+
delete rootStyle[name];
|
|
51
|
+
save();
|
|
52
|
+
renderCssVarsEditor(container);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const addVar = (
|
|
56
|
+
/** @type {string} */ prefix,
|
|
57
|
+
/** @type {string} */ friendlyName,
|
|
58
|
+
/** @type {string} */ val,
|
|
59
|
+
) => {
|
|
60
|
+
const varName = friendlyNameToVar(friendlyName, prefix);
|
|
61
|
+
if (!varName || !val) return;
|
|
62
|
+
rootStyle[varName] = val;
|
|
63
|
+
save();
|
|
64
|
+
renderCssVarsEditor(container);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const tpl = html`
|
|
68
|
+
<div class="settings-section">
|
|
69
|
+
<h3 class="settings-section-title">CSS Variables</h3>
|
|
70
|
+
|
|
71
|
+
${renderColorSection(groups.color, updateVar, deleteVar, addVar)}
|
|
72
|
+
${renderFontSection(groups.font, updateVar, deleteVar, addVar)}
|
|
73
|
+
${renderSizeSection(groups.size, updateVar, deleteVar, addVar, rootStyle, mediaNames)}
|
|
74
|
+
${groups.other.length > 0
|
|
75
|
+
? renderOtherSection(groups.other, updateVar, deleteVar, addVar, rootStyle, mediaNames)
|
|
76
|
+
: nothing}
|
|
77
|
+
</div>
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
litRender(tpl, container);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {[string, any][]} vars
|
|
85
|
+
* @param {Function} updateVar
|
|
86
|
+
* @param {Function} deleteVar
|
|
87
|
+
* @param {Function} addVar
|
|
88
|
+
*/
|
|
89
|
+
function renderColorSection(vars, updateVar, deleteVar, addVar) {
|
|
90
|
+
return html`
|
|
91
|
+
<div class="css-vars-group">
|
|
92
|
+
<h4 class="css-vars-group-title">Colors</h4>
|
|
93
|
+
${vars.map(
|
|
94
|
+
([name, val]) => html`
|
|
95
|
+
<div class="css-var-row">
|
|
96
|
+
<div class="css-var-swatch" style="background:${val}">
|
|
97
|
+
<input
|
|
98
|
+
type="color"
|
|
99
|
+
.value=${val && val.startsWith("#") ? val : "#007acc"}
|
|
100
|
+
@input=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
<span class="css-var-name">${varDisplayName(name, "--color-")}</span>
|
|
104
|
+
<sp-textfield
|
|
105
|
+
size="s"
|
|
106
|
+
.value=${String(val)}
|
|
107
|
+
@change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
108
|
+
style="flex:1;max-width:160px"
|
|
109
|
+
></sp-textfield>
|
|
110
|
+
<sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
|
|
111
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
112
|
+
</sp-action-button>
|
|
113
|
+
</div>
|
|
114
|
+
`,
|
|
115
|
+
)}
|
|
116
|
+
${renderAddRow("--color-", "Primary Blue", "#007acc", addVar)}
|
|
117
|
+
</div>
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {[string, any][]} vars
|
|
123
|
+
* @param {Function} updateVar
|
|
124
|
+
* @param {Function} deleteVar
|
|
125
|
+
* @param {Function} addVar
|
|
126
|
+
*/
|
|
127
|
+
function renderFontSection(vars, updateVar, deleteVar, addVar) {
|
|
128
|
+
return html`
|
|
129
|
+
<div class="css-vars-group">
|
|
130
|
+
<h4 class="css-vars-group-title">Fonts</h4>
|
|
131
|
+
${vars.map(
|
|
132
|
+
([name, val]) => html`
|
|
133
|
+
<div class="css-var-row">
|
|
134
|
+
<span class="css-var-name">${varDisplayName(name, "--font-")}</span>
|
|
135
|
+
<sp-textfield
|
|
136
|
+
size="s"
|
|
137
|
+
.value=${String(val)}
|
|
138
|
+
@change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
139
|
+
style="flex:1"
|
|
140
|
+
></sp-textfield>
|
|
141
|
+
<sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
|
|
142
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
143
|
+
</sp-action-button>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="css-var-font-preview" style="font-family:${val}">
|
|
146
|
+
The quick brown fox jumps over the lazy dog
|
|
147
|
+
</div>
|
|
148
|
+
`,
|
|
149
|
+
)}
|
|
150
|
+
${renderAddRow("--font-", "Body Serif", "'Georgia', serif", addVar)}
|
|
151
|
+
</div>
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {[string, any][]} vars
|
|
157
|
+
* @param {Function} updateVar
|
|
158
|
+
* @param {Function} deleteVar
|
|
159
|
+
* @param {Function} addVar
|
|
160
|
+
* @param {any} rootStyle
|
|
161
|
+
* @param {string[]} mediaNames
|
|
162
|
+
*/
|
|
163
|
+
function renderSizeSection(vars, updateVar, deleteVar, addVar, rootStyle, mediaNames) {
|
|
164
|
+
return html`
|
|
165
|
+
<div class="css-vars-group">
|
|
166
|
+
<h4 class="css-vars-group-title">Sizes & Spacing</h4>
|
|
167
|
+
${vars.map(
|
|
168
|
+
([name, val]) => html`
|
|
169
|
+
<div class="css-var-row">
|
|
170
|
+
<span class="css-var-name"
|
|
171
|
+
>${varDisplayName(name, "--size-") ||
|
|
172
|
+
varDisplayName(name, "--spacing-") ||
|
|
173
|
+
varDisplayName(name, "--radius-") ||
|
|
174
|
+
name}</span
|
|
175
|
+
>
|
|
176
|
+
<sp-textfield
|
|
177
|
+
size="s"
|
|
178
|
+
.value=${String(val)}
|
|
179
|
+
@change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
180
|
+
style="max-width:120px"
|
|
181
|
+
></sp-textfield>
|
|
182
|
+
<sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
|
|
183
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
184
|
+
</sp-action-button>
|
|
185
|
+
</div>
|
|
186
|
+
${mediaNames.length > 0 ? renderMediaOverrides(name, rootStyle, mediaNames) : nothing}
|
|
187
|
+
`,
|
|
188
|
+
)}
|
|
189
|
+
${renderAddRow("--size-", "Spacing Large", "32px", addVar)}
|
|
190
|
+
</div>
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {[string, any][]} vars
|
|
196
|
+
* @param {Function} updateVar
|
|
197
|
+
* @param {Function} deleteVar
|
|
198
|
+
* @param {Function} addVar
|
|
199
|
+
* @param {any} rootStyle
|
|
200
|
+
* @param {string[]} mediaNames
|
|
201
|
+
*/
|
|
202
|
+
function renderOtherSection(vars, updateVar, deleteVar, addVar, rootStyle, mediaNames) {
|
|
203
|
+
return html`
|
|
204
|
+
<div class="css-vars-group">
|
|
205
|
+
<h4 class="css-vars-group-title">Other</h4>
|
|
206
|
+
${vars.map(
|
|
207
|
+
([name, val]) => html`
|
|
208
|
+
<div class="css-var-row">
|
|
209
|
+
<span class="css-var-name">${name}</span>
|
|
210
|
+
<sp-textfield
|
|
211
|
+
size="s"
|
|
212
|
+
.value=${String(val)}
|
|
213
|
+
@change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
214
|
+
style="flex:1"
|
|
215
|
+
></sp-textfield>
|
|
216
|
+
<sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
|
|
217
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
218
|
+
</sp-action-button>
|
|
219
|
+
</div>
|
|
220
|
+
${mediaNames.length > 0 ? renderMediaOverrides(name, rootStyle, mediaNames) : nothing}
|
|
221
|
+
`,
|
|
222
|
+
)}
|
|
223
|
+
${renderAddRow("--", "Custom Var", "value", addVar)}
|
|
224
|
+
</div>
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {string} varName
|
|
230
|
+
* @param {any} rootStyle
|
|
231
|
+
* @param {string[]} mediaNames
|
|
232
|
+
*/
|
|
233
|
+
function renderMediaOverrides(varName, rootStyle, mediaNames) {
|
|
234
|
+
const overrides = [];
|
|
235
|
+
for (const mediaName of mediaNames) {
|
|
236
|
+
const mediaKey = `@${mediaName}`;
|
|
237
|
+
const mediaBlock = rootStyle[mediaKey];
|
|
238
|
+
if (mediaBlock && typeof mediaBlock === "object" && mediaBlock[varName]) {
|
|
239
|
+
overrides.push({ mediaName, value: mediaBlock[varName] });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (overrides.length === 0) return nothing;
|
|
244
|
+
|
|
245
|
+
return html`
|
|
246
|
+
<div class="css-var-media-overrides">
|
|
247
|
+
${overrides.map(
|
|
248
|
+
(o) => html`
|
|
249
|
+
<div class="css-var-media-row">
|
|
250
|
+
<span class="css-var-media-label">@${o.mediaName}</span>
|
|
251
|
+
<sp-textfield
|
|
252
|
+
size="s"
|
|
253
|
+
.value=${String(o.value)}
|
|
254
|
+
@change=${(/** @type {any} */ e) => {
|
|
255
|
+
if (!rootStyle[`@${o.mediaName}`]) rootStyle[`@${o.mediaName}`] = {};
|
|
256
|
+
rootStyle[`@${o.mediaName}`][varName] = e.target.value;
|
|
257
|
+
updateSiteConfig({ style: { ...rootStyle } });
|
|
258
|
+
}}
|
|
259
|
+
style="max-width:120px"
|
|
260
|
+
></sp-textfield>
|
|
261
|
+
</div>
|
|
262
|
+
`,
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {string} prefix
|
|
270
|
+
* @param {string} placeholder
|
|
271
|
+
* @param {string} valuePlaceholder
|
|
272
|
+
* @param {Function} addVar
|
|
273
|
+
*/
|
|
274
|
+
function renderAddRow(prefix, placeholder, valuePlaceholder, addVar) {
|
|
275
|
+
/** @type {any} */
|
|
276
|
+
let nameEl = null;
|
|
277
|
+
/** @type {any} */
|
|
278
|
+
let valEl = null;
|
|
279
|
+
|
|
280
|
+
return html`
|
|
281
|
+
<div class="css-var-add-row">
|
|
282
|
+
<sp-textfield
|
|
283
|
+
size="s"
|
|
284
|
+
placeholder=${placeholder}
|
|
285
|
+
${ref((el) => {
|
|
286
|
+
if (el) nameEl = el;
|
|
287
|
+
})}
|
|
288
|
+
></sp-textfield>
|
|
289
|
+
<sp-textfield
|
|
290
|
+
size="s"
|
|
291
|
+
placeholder=${valuePlaceholder}
|
|
292
|
+
${ref((el) => {
|
|
293
|
+
if (el) valEl = el;
|
|
294
|
+
})}
|
|
295
|
+
></sp-textfield>
|
|
296
|
+
<sp-action-button
|
|
297
|
+
size="s"
|
|
298
|
+
@click=${() => {
|
|
299
|
+
if (nameEl && valEl) {
|
|
300
|
+
addVar(prefix, nameEl.value, valEl.value);
|
|
301
|
+
}
|
|
302
|
+
}}
|
|
303
|
+
>Add</sp-action-button
|
|
304
|
+
>
|
|
305
|
+
</div>
|
|
306
|
+
`;
|
|
307
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** General settings section — favicon, platform adapter, and other site-wide config. */
|
|
2
|
+
|
|
3
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
4
|
+
import { projectState } from "../store.js";
|
|
5
|
+
import { updateSiteConfig } from "../site-context.js";
|
|
6
|
+
import { getPlatform } from "../platform.js";
|
|
7
|
+
import { openFileInTab } from "../files/files.js";
|
|
8
|
+
import { closeSettingsModal } from "./settings-modal.js";
|
|
9
|
+
|
|
10
|
+
/** @param {HTMLElement} container */
|
|
11
|
+
export function renderGeneralSettings(container) {
|
|
12
|
+
const config = projectState.projectConfig || {};
|
|
13
|
+
|
|
14
|
+
const onFaviconUpload = async () => {
|
|
15
|
+
const input = document.createElement("input");
|
|
16
|
+
input.type = "file";
|
|
17
|
+
input.accept = "image/*,.ico,.svg";
|
|
18
|
+
input.onchange = async () => {
|
|
19
|
+
const file = input.files?.[0];
|
|
20
|
+
if (!file) return;
|
|
21
|
+
const platform = getPlatform();
|
|
22
|
+
await platform.uploadFile("public/favicon.ico", file);
|
|
23
|
+
await updateSiteConfig({ favicon: "/favicon.ico" });
|
|
24
|
+
renderGeneralSettings(container);
|
|
25
|
+
};
|
|
26
|
+
input.click();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const onAdapterChange = (/** @type {any} */ e) => {
|
|
30
|
+
updateSiteConfig({ adapter: e.target.value });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const onEditGlobalStyles = () => {
|
|
34
|
+
closeSettingsModal();
|
|
35
|
+
openFileInTab("project.json");
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const currentFavicon = config.favicon;
|
|
39
|
+
|
|
40
|
+
const tpl = html`
|
|
41
|
+
<div class="settings-section">
|
|
42
|
+
<h3 class="settings-section-title">General</h3>
|
|
43
|
+
|
|
44
|
+
<div class="settings-field">
|
|
45
|
+
<label class="settings-field-label">Favicon</label>
|
|
46
|
+
<p class="settings-field-desc">Upload an image to use as the site favicon.</p>
|
|
47
|
+
<div style="display:flex;align-items:center;gap:12px">
|
|
48
|
+
${currentFavicon
|
|
49
|
+
? html`<img
|
|
50
|
+
src=${currentFavicon}
|
|
51
|
+
alt="Current favicon"
|
|
52
|
+
style="width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:4px;padding:2px"
|
|
53
|
+
/>`
|
|
54
|
+
: html`<div
|
|
55
|
+
style="width:32px;height:32px;border:1px dashed var(--border);border-radius:4px;display:flex;align-items:center;justify-content:center;color:var(--fg-dim);font-size:11px"
|
|
56
|
+
>
|
|
57
|
+
—
|
|
58
|
+
</div>`}
|
|
59
|
+
<sp-action-button size="s" @click=${onFaviconUpload}> Upload Favicon </sp-action-button>
|
|
60
|
+
${currentFavicon
|
|
61
|
+
? html`<span style="font-size:11px;color:var(--fg-dim)">${currentFavicon}</span>`
|
|
62
|
+
: nothing}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="settings-field">
|
|
67
|
+
<label class="settings-field-label">Platform Adapter</label>
|
|
68
|
+
<p class="settings-field-desc">Build adapter for deployment target.</p>
|
|
69
|
+
<sp-picker
|
|
70
|
+
size="s"
|
|
71
|
+
label="Platform Adapter"
|
|
72
|
+
.value=${config.adapter || "static"}
|
|
73
|
+
@change=${onAdapterChange}
|
|
74
|
+
>
|
|
75
|
+
<sp-menu-item value="static">Static</sp-menu-item>
|
|
76
|
+
<sp-menu-item value="bun">Bun</sp-menu-item>
|
|
77
|
+
<sp-menu-item value="node">Node</sp-menu-item>
|
|
78
|
+
<sp-menu-item value="cloudflare-workers">Cloudflare Workers</sp-menu-item>
|
|
79
|
+
<sp-menu-item value="cloudflare-pages">Cloudflare Pages</sp-menu-item>
|
|
80
|
+
</sp-picker>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="settings-field">
|
|
84
|
+
<label class="settings-field-label">Global Styles</label>
|
|
85
|
+
<p class="settings-field-desc">Edit default element styles that apply across all pages.</p>
|
|
86
|
+
<sp-action-button size="s" @click=${onEditGlobalStyles}>
|
|
87
|
+
Edit Global Styles
|
|
88
|
+
</sp-action-button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
litRender(tpl, container);
|
|
94
|
+
}
|