mirror of
https://github.com/wavetermdev/waveterm
synced 2026-04-21 14:37:16 +00:00
UI only preview server (+ deployments) (#2919)
adds a new "preview server" for UI testing. hooking up to cloudflare pages deployments. for now, just a test "about modal" component. will add more later.
This commit is contained in:
parent
195277de45
commit
9abd590176
12 changed files with 466 additions and 233 deletions
14
Taskfile.yml
14
Taskfile.yml
|
|
@ -58,6 +58,20 @@ tasks:
|
|||
WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/"
|
||||
WAVETERM_NOCONFIRMQUIT: "1"
|
||||
|
||||
preview:
|
||||
desc: Run the standalone component preview server with HMR (no Electron, no backend).
|
||||
dir: frontend/preview
|
||||
cmd: npx vite
|
||||
deps:
|
||||
- npm:install
|
||||
|
||||
build:preview:
|
||||
desc: Build the component preview server for static deployment.
|
||||
dir: frontend/preview
|
||||
cmd: npx vite build
|
||||
deps:
|
||||
- npm:install
|
||||
|
||||
electron:winquickdev:
|
||||
desc: Run the Electron application via the Vite dev server (quick dev - Windows amd64 only, no generate, no wsh).
|
||||
cmd: npm run dev
|
||||
|
|
|
|||
|
|
@ -9,15 +9,17 @@ import { isDev } from "@/util/isdev";
|
|||
import { useState } from "react";
|
||||
import { getApi } from "../store/global";
|
||||
|
||||
interface AboutModalProps {}
|
||||
interface AboutModalVProps {
|
||||
versionString: string;
|
||||
updaterChannel: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AboutModal = ({}: AboutModalProps) => {
|
||||
const AboutModalV = ({ versionString, updaterChannel, onClose }: AboutModalVProps) => {
|
||||
const currentDate = new Date();
|
||||
const [details] = useState(() => getApi().getAboutModalDetails());
|
||||
const [updaterChannel] = useState(() => getApi().getUpdaterChannel());
|
||||
|
||||
return (
|
||||
<Modal className="pt-[34px] pb-[34px]" onClose={() => modalsModel.popModal()}>
|
||||
<Modal className="pt-[34px] pb-[34px]" onClose={onClose}>
|
||||
<div className="flex flex-col gap-[26px] w-full">
|
||||
<div className="flex flex-col items-center justify-center gap-4 self-stretch w-full text-center">
|
||||
<Logo />
|
||||
|
|
@ -29,8 +31,7 @@ const AboutModal = ({}: AboutModalProps) => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="items-center gap-4 self-stretch w-full text-center">
|
||||
Client Version {details.version} ({isDev() ? "dev-" : ""}
|
||||
{details.buildTime})
|
||||
Client Version {versionString}
|
||||
<br />
|
||||
Update Channel: {updaterChannel}
|
||||
</div>
|
||||
|
|
@ -68,6 +69,18 @@ const AboutModal = ({}: AboutModalProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
AboutModalV.displayName = "AboutModalV";
|
||||
|
||||
interface AboutModalProps {}
|
||||
|
||||
const AboutModal = ({}: AboutModalProps) => {
|
||||
const [details] = useState(() => getApi().getAboutModalDetails());
|
||||
const [updaterChannel] = useState(() => getApi().getUpdaterChannel());
|
||||
const versionString = `${details.version} (${isDev() ? "dev-" : ""}${details.buildTime})`;
|
||||
|
||||
return <AboutModalV versionString={versionString} updaterChannel={updaterChannel} onClose={() => modalsModel.popModal()} />;
|
||||
};
|
||||
|
||||
AboutModal.displayName = "AboutModal";
|
||||
|
||||
export { AboutModal };
|
||||
export { AboutModal, AboutModalV };
|
||||
|
|
|
|||
|
|
@ -37,233 +37,229 @@ interface TabProps {
|
|||
onLoaded: () => void;
|
||||
}
|
||||
|
||||
const Tab = memo(
|
||||
forwardRef<HTMLDivElement, TabProps>(
|
||||
(
|
||||
{ id, active, isBeforeActive, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart },
|
||||
ref
|
||||
) => {
|
||||
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
|
||||
const [originalName, setOriginalName] = useState("");
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const indicator = useAtomValue(getTabIndicatorAtom(id));
|
||||
const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
|
||||
const { id, active, isBeforeActive, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props;
|
||||
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
|
||||
const [originalName, setOriginalName] = useState("");
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const indicator = useAtomValue(getTabIndicatorAtom(id));
|
||||
|
||||
const editableRef = useRef<HTMLDivElement>(null);
|
||||
const editableTimeoutRef = useRef<NodeJS.Timeout>(null);
|
||||
const loadedRef = useRef(false);
|
||||
const tabRef = useRef<HTMLDivElement>(null);
|
||||
const editableRef = useRef<HTMLDivElement>(null);
|
||||
const editableTimeoutRef = useRef<NodeJS.Timeout>(null);
|
||||
const loadedRef = useRef(false);
|
||||
const tabRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);
|
||||
useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabData?.name) {
|
||||
setOriginalName(tabData.name);
|
||||
}
|
||||
}, [tabData]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (editableTimeoutRef.current) {
|
||||
clearTimeout(editableTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectEditableText = useCallback(() => {
|
||||
if (!editableRef.current) {
|
||||
return;
|
||||
}
|
||||
editableRef.current.focus();
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.selectNodeContents(editableRef.current);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}, []);
|
||||
|
||||
const handleRenameTab: React.MouseEventHandler<HTMLDivElement> = (event) => {
|
||||
event?.stopPropagation();
|
||||
setIsEditable(true);
|
||||
editableTimeoutRef.current = setTimeout(() => {
|
||||
selectEditableText();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
let newText = editableRef.current.innerText.trim();
|
||||
newText = newText || originalName;
|
||||
editableRef.current.innerText = newText;
|
||||
setIsEditable(false);
|
||||
fireAndForget(() => ObjectService.UpdateTabName(id, newText));
|
||||
setTimeout(() => refocusNode(null), 10);
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
|
||||
event.preventDefault();
|
||||
selectEditableText();
|
||||
return;
|
||||
}
|
||||
// this counts glyphs, not characters
|
||||
const curLen = Array.from(editableRef.current.innerText).length;
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (editableRef.current.innerText.trim() === "") {
|
||||
editableRef.current.innerText = originalName;
|
||||
}
|
||||
editableRef.current.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
editableRef.current.innerText = originalName;
|
||||
editableRef.current.blur();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadedRef.current) {
|
||||
onLoaded();
|
||||
loadedRef.current = true;
|
||||
}
|
||||
}, [onLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabRef.current && isNew) {
|
||||
const initialWidth = `${(tabWidth / 3) * 2}px`;
|
||||
tabRef.current.style.setProperty("--initial-tab-width", initialWidth);
|
||||
tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`);
|
||||
}
|
||||
}, [isNew, tabWidth]);
|
||||
|
||||
// Prevent drag from being triggered on mousedown
|
||||
const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleTabClick = () => {
|
||||
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
|
||||
if (currentIndicator?.clearonfocus) {
|
||||
clearTabIndicatorFromFocus(id);
|
||||
}
|
||||
onSelect();
|
||||
};
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
let menu: ContextMenuItem[] = [];
|
||||
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
|
||||
if (currentIndicator) {
|
||||
menu.push(
|
||||
{
|
||||
label: "Clear Tab Indicator",
|
||||
click: () => setTabIndicator(id, null),
|
||||
},
|
||||
{
|
||||
label: "Clear All Indicators",
|
||||
click: () => clearAllTabIndicators(),
|
||||
},
|
||||
{ type: "separator" }
|
||||
);
|
||||
}
|
||||
menu.push(
|
||||
{ label: "Rename Tab", click: () => handleRenameTab(null) },
|
||||
{
|
||||
label: "Copy TabId",
|
||||
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
|
||||
},
|
||||
{ type: "separator" }
|
||||
);
|
||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||
const bgPresets: string[] = [];
|
||||
for (const key in fullConfig?.presets ?? {}) {
|
||||
if (key.startsWith("bg@")) {
|
||||
bgPresets.push(key);
|
||||
}
|
||||
}
|
||||
bgPresets.sort((a, b) => {
|
||||
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
|
||||
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
if (bgPresets.length > 0) {
|
||||
const submenu: ContextMenuItem[] = [];
|
||||
const oref = makeORef("tab", id);
|
||||
for (const presetName of bgPresets) {
|
||||
const preset = fullConfig.presets[presetName];
|
||||
if (preset == null) {
|
||||
continue;
|
||||
}
|
||||
submenu.push({
|
||||
label: preset["display:name"] ?? presetName,
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
await ObjectService.UpdateObjectMeta(oref, preset);
|
||||
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
|
||||
recordTEvent("action:settabtheme");
|
||||
}),
|
||||
});
|
||||
}
|
||||
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
|
||||
}
|
||||
menu.push({ label: "Close Tab", click: () => onClose(null) });
|
||||
ContextMenuModel.showContextMenu(menu, e);
|
||||
},
|
||||
[handleRenameTab, id, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tabRef}
|
||||
className={clsx("tab", {
|
||||
active,
|
||||
dragging: isDragging,
|
||||
"before-active": isBeforeActive,
|
||||
"new-tab": isNew,
|
||||
})}
|
||||
onMouseDown={onDragStart}
|
||||
onClick={handleTabClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-tab-id={id}
|
||||
>
|
||||
<div className="tab-inner">
|
||||
<div
|
||||
ref={editableRef}
|
||||
className={clsx("name", { focused: isEditable })}
|
||||
contentEditable={isEditable}
|
||||
onDoubleClick={handleRenameTab}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
>
|
||||
{tabData?.name}
|
||||
</div>
|
||||
{indicator && (
|
||||
<div
|
||||
className="tab-indicator pointer-events-none"
|
||||
style={{ color: indicator.color || "#fbbf24" }}
|
||||
title="Activity notification"
|
||||
>
|
||||
<i className={makeIconClass(indicator.icon, true, { defaultIcon: "bell" })} />
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="ghost grey close"
|
||||
onClick={onClose}
|
||||
onMouseDown={handleMouseDownOnClose}
|
||||
title="Close Tab"
|
||||
>
|
||||
<i className="fa fa-solid fa-xmark" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (tabData?.name) {
|
||||
setOriginalName(tabData.name);
|
||||
}
|
||||
)
|
||||
);
|
||||
}, [tabData]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (editableTimeoutRef.current) {
|
||||
clearTimeout(editableTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectEditableText = useCallback(() => {
|
||||
if (!editableRef.current) {
|
||||
return;
|
||||
}
|
||||
editableRef.current.focus();
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.selectNodeContents(editableRef.current);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}, []);
|
||||
|
||||
const handleRenameTab: React.MouseEventHandler<HTMLDivElement> = (event) => {
|
||||
event?.stopPropagation();
|
||||
setIsEditable(true);
|
||||
editableTimeoutRef.current = setTimeout(() => {
|
||||
selectEditableText();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
let newText = editableRef.current.innerText.trim();
|
||||
newText = newText || originalName;
|
||||
editableRef.current.innerText = newText;
|
||||
setIsEditable(false);
|
||||
fireAndForget(() => ObjectService.UpdateTabName(id, newText));
|
||||
setTimeout(() => refocusNode(null), 10);
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
|
||||
event.preventDefault();
|
||||
selectEditableText();
|
||||
return;
|
||||
}
|
||||
// this counts glyphs, not characters
|
||||
const curLen = Array.from(editableRef.current.innerText).length;
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (editableRef.current.innerText.trim() === "") {
|
||||
editableRef.current.innerText = originalName;
|
||||
}
|
||||
editableRef.current.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
editableRef.current.innerText = originalName;
|
||||
editableRef.current.blur();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadedRef.current) {
|
||||
onLoaded();
|
||||
loadedRef.current = true;
|
||||
}
|
||||
}, [onLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabRef.current && isNew) {
|
||||
const initialWidth = `${(tabWidth / 3) * 2}px`;
|
||||
tabRef.current.style.setProperty("--initial-tab-width", initialWidth);
|
||||
tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`);
|
||||
}
|
||||
}, [isNew, tabWidth]);
|
||||
|
||||
// Prevent drag from being triggered on mousedown
|
||||
const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleTabClick = () => {
|
||||
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
|
||||
if (currentIndicator?.clearonfocus) {
|
||||
clearTabIndicatorFromFocus(id);
|
||||
}
|
||||
onSelect();
|
||||
};
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
let menu: ContextMenuItem[] = [];
|
||||
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
|
||||
if (currentIndicator) {
|
||||
menu.push(
|
||||
{
|
||||
label: "Clear Tab Indicator",
|
||||
click: () => setTabIndicator(id, null),
|
||||
},
|
||||
{
|
||||
label: "Clear All Indicators",
|
||||
click: () => clearAllTabIndicators(),
|
||||
},
|
||||
{ type: "separator" }
|
||||
);
|
||||
}
|
||||
menu.push(
|
||||
{ label: "Rename Tab", click: () => handleRenameTab(null) },
|
||||
{
|
||||
label: "Copy TabId",
|
||||
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
|
||||
},
|
||||
{ type: "separator" }
|
||||
);
|
||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||
const bgPresets: string[] = [];
|
||||
for (const key in fullConfig?.presets ?? {}) {
|
||||
if (key.startsWith("bg@")) {
|
||||
bgPresets.push(key);
|
||||
}
|
||||
}
|
||||
bgPresets.sort((a, b) => {
|
||||
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
|
||||
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
if (bgPresets.length > 0) {
|
||||
const submenu: ContextMenuItem[] = [];
|
||||
const oref = makeORef("tab", id);
|
||||
for (const presetName of bgPresets) {
|
||||
const preset = fullConfig.presets[presetName];
|
||||
if (preset == null) {
|
||||
continue;
|
||||
}
|
||||
submenu.push({
|
||||
label: preset["display:name"] ?? presetName,
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
await ObjectService.UpdateObjectMeta(oref, preset);
|
||||
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
|
||||
recordTEvent("action:settabtheme");
|
||||
}),
|
||||
});
|
||||
}
|
||||
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
|
||||
}
|
||||
menu.push({ label: "Close Tab", click: () => onClose(null) });
|
||||
ContextMenuModel.showContextMenu(menu, e);
|
||||
},
|
||||
[handleRenameTab, id, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tabRef}
|
||||
className={clsx("tab", {
|
||||
active,
|
||||
dragging: isDragging,
|
||||
"before-active": isBeforeActive,
|
||||
"new-tab": isNew,
|
||||
})}
|
||||
onMouseDown={onDragStart}
|
||||
onClick={handleTabClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-tab-id={id}
|
||||
>
|
||||
<div className="tab-inner">
|
||||
<div
|
||||
ref={editableRef}
|
||||
className={clsx("name", { focused: isEditable })}
|
||||
contentEditable={isEditable}
|
||||
onDoubleClick={handleRenameTab}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
>
|
||||
{tabData?.name}
|
||||
</div>
|
||||
{indicator && (
|
||||
<div
|
||||
className="tab-indicator pointer-events-none"
|
||||
style={{ color: indicator.color || "#fbbf24" }}
|
||||
title="Activity notification"
|
||||
>
|
||||
<i className={makeIconClass(indicator.icon, true, { defaultIcon: "bell" })} />
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="ghost grey close"
|
||||
onClick={onClose}
|
||||
onMouseDown={handleMouseDownOnClose}
|
||||
title="Close Tab"
|
||||
>
|
||||
<i className="fa fa-solid fa-xmark" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const Tab = memo(TabInner);
|
||||
Tab.displayName = "Tab";
|
||||
|
||||
export { Tab };
|
||||
|
|
|
|||
19
frontend/preview/index.html
Normal file
19
frontend/preview/index.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Wave Preview Server</title>
|
||||
<link rel="stylesheet" href="/fontawesome/css/fontawesome.min.css" />
|
||||
<link rel="stylesheet" href="/fontawesome/css/brands.min.css" />
|
||||
<link rel="stylesheet" href="/fontawesome/css/solid.min.css" />
|
||||
<link rel="stylesheet" href="/fontawesome/css/sharp-solid.min.css" />
|
||||
<link rel="stylesheet" href="/fontawesome/css/sharp-regular.min.css" />
|
||||
<link rel="stylesheet" href="/fontawesome/css/custom-icons.min.css" />
|
||||
</head>
|
||||
<body class="init" data-colorscheme="dark">
|
||||
<div id="main"></div>
|
||||
<script type="module" src="./preview.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
frontend/preview/preview.css
Normal file
8
frontend/preview/preview.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/* Copyright 2026, Command Line Inc.
|
||||
SPDX-License-Identifier: Apache-2.0 */
|
||||
|
||||
/* Re-export the main tailwind setup, adding extra @source so Tailwind v4
|
||||
scans the full frontend/app tree (the preview vite root is frontend/preview/,
|
||||
so the automatic scan would otherwise miss frontend/app/**). */
|
||||
@import "../tailwindsetup.css";
|
||||
@source "../app";
|
||||
117
frontend/preview/preview.tsx
Normal file
117
frontend/preview/preview.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Logo from "@/app/asset/logo.svg";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import "../app/app.scss";
|
||||
|
||||
// preview.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports)
|
||||
// preview.css re-exports tailwindsetup.css and adds @source "../app" so Tailwind v4 scans frontend/app/** for class names
|
||||
import "./preview.css";
|
||||
|
||||
// Vite glob import — statically analyzed at build time, lazily loaded at runtime.
|
||||
// Each *.preview.tsx file is auto-discovered; its filename (minus the suffix) becomes the key.
|
||||
// Files may use a default export or any named export — the first export found is used as the component.
|
||||
const previewModules = import.meta.glob<{ default?: React.ComponentType; [key: string]: unknown }>(
|
||||
"./previews/*.preview.tsx"
|
||||
);
|
||||
|
||||
// Derive a human-readable key from the file path, e.g.:
|
||||
// "./previews/modal-about.preview.tsx" → "modal-about"
|
||||
function pathToKey(path: string): string {
|
||||
return path.replace(/^\.\/previews\//, "").replace(/\.preview\.tsx$/, "");
|
||||
}
|
||||
|
||||
// Build a map of key → lazy React component.
|
||||
// Each preview file is expected to have a default export that is the preview component.
|
||||
const previews: Record<string, React.LazyExoticComponent<React.ComponentType>> = Object.fromEntries(
|
||||
Object.entries(previewModules).map(([path, loader]) => [
|
||||
pathToKey(path),
|
||||
lazy(() =>
|
||||
loader().then((mod) => ({ default: (mod.default ?? Object.values(mod)[0]) as React.ComponentType }))
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
function PreviewIndex() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground font-sans flex flex-col items-center justify-center gap-6">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Logo />
|
||||
<h1 className="text-title font-semibold tracking-tight text-foreground">Wave Preview Server</h1>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-8 bg-border" />
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted text-xs mb-1">Available previews:</p>
|
||||
{Object.keys(previews).map((name) => (
|
||||
<a
|
||||
key={name}
|
||||
href={`?preview=${name}`}
|
||||
className="font-mono text-accent bg-accentbg px-3 py-1.5 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewHeader({ previewName }: { previewName: string }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 flex items-center gap-3 px-4 py-2 bg-panel border-b border-border"
|
||||
style={{ zIndex: 100000 }}
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 text-accent text-sm hover:opacity-80 transition-opacity font-mono"
|
||||
>
|
||||
← index
|
||||
</a>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<span className="text-muted text-xs font-mono">{previewName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewApp() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const previewName = params.get("preview");
|
||||
|
||||
if (previewName) {
|
||||
const PreviewComponent = previews[previewName];
|
||||
if (PreviewComponent) {
|
||||
return (
|
||||
<>
|
||||
<PreviewHeader previewName={previewName} />
|
||||
<div className="min-h-screen bg-background text-foreground font-sans flex flex-col items-center justify-center">
|
||||
<Suspense fallback={null}>
|
||||
<PreviewComponent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PreviewHeader previewName={previewName} />
|
||||
<div className="min-h-screen bg-background text-foreground font-sans flex flex-col items-center justify-center gap-4">
|
||||
<p className="text-error">Preview not found: {previewName}</p>
|
||||
<a href="/" className="text-accent text-sm hover:opacity-80">
|
||||
← Back to index
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <PreviewIndex />;
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById("main")!);
|
||||
root.render(<PreviewApp />);
|
||||
0
frontend/preview/previews/.gitkeep
Normal file
0
frontend/preview/previews/.gitkeep
Normal file
14
frontend/preview/previews/modal-about.preview.tsx
Normal file
14
frontend/preview/previews/modal-about.preview.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { AboutModalV } from "@/app/modals/about";
|
||||
|
||||
export function AboutModalPreview() {
|
||||
return (
|
||||
<AboutModalV
|
||||
versionString="0.11.0 (1740000000)"
|
||||
updaterChannel="stable"
|
||||
onClose={() => console.log("close")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
frontend/preview/vite.config.ts
Normal file
29
frontend/preview/vite.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
base: "./",
|
||||
// Serve the workspace-root public/ directory so Font Awesome and other
|
||||
// static assets (served by Electron in the real app) are available here too.
|
||||
publicDir: path.resolve(__dirname, "../../public"),
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
svgr({
|
||||
svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true },
|
||||
include: "**/*.svg",
|
||||
}),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
port: 7007,
|
||||
},
|
||||
});
|
||||
12
frontend/types/media.d.ts
vendored
12
frontend/types/media.d.ts
vendored
|
|
@ -266,3 +266,15 @@ declare interface VitePreloadErrorEvent extends Event {
|
|||
declare interface WindowEventMap {
|
||||
"vite:preloadError": VitePreloadErrorEvent;
|
||||
}
|
||||
|
||||
// import.meta.glob — provided by Vite at build time
|
||||
interface ImportMeta {
|
||||
glob<T = Record<string, unknown>>(
|
||||
pattern: string | string[],
|
||||
options?: { eager?: boolean; import?: string; query?: string | Record<string, string> }
|
||||
): Record<string, () => Promise<T>>;
|
||||
glob<T = Record<string, unknown>>(
|
||||
pattern: string | string[],
|
||||
options: { eager: true; import?: string; query?: string | Record<string, string> }
|
||||
): Record<string, T>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
"build:prod": "electron-vite build --mode production",
|
||||
"coverage": "vitest run --coverage",
|
||||
"test": "vitest",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
"postinstall": "node ./postinstall.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^8.57.0",
|
||||
|
|
|
|||
11
postinstall.cjs
Normal file
11
postinstall.cjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const skip =
|
||||
process.env.WAVETERM_SKIP_APP_DEPS === "1" || process.env.CF_PAGES === "1" || process.env.CF_PAGES === "true";
|
||||
|
||||
if (skip) {
|
||||
console.log("postinstall: skipping electron-builder install-app-deps");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
import("child_process").then(({ execSync }) => {
|
||||
execSync("electron-builder install-app-deps", { stdio: "inherit" });
|
||||
});
|
||||
Loading…
Reference in a new issue