Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

37 changes: 36 additions & 1 deletion src/frontend/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@
height: 100vh;
background-color: ${backgroundColor};
}

#loading-container img {
width: 80px;
height: 80px;
object-fit: contain;
}

#loading-container img:not([src]) {
display: none;
}
`;
document.head.appendChild(loadingContainerStyle);
</script>
Expand All @@ -66,8 +76,33 @@

<body>
<div id="root">
<div id="loading-container"></div>
<div id="loading-container">
<img id="loading-icon" alt="" />
</div>
</div>
<script>
(function () {
var config = window.BRAND_CONFIG || {};
var loadingIcon =
config.URLLoadingIcon ||
config.loadingIcon ||
(config.loading && config.loading.icon && config.loading.icon.url) ||
"";
var iconElement = document.getElementById("loading-icon");

if (!loadingIcon || !iconElement) return;

var absoluteUrlPattern = /^(https?:|data:|blob:|\/\/)/i;
var resolvedIcon = loadingIcon;

if (!absoluteUrlPattern.test(loadingIcon)) {
var baseUrl = (brandBaseUrl || "").replace(/\/$/, "");
resolvedIcon = baseUrl + (loadingIcon.charAt(0) === "/" ? loadingIcon : "/" + loadingIcon);
}

iconElement.setAttribute("src", resolvedIcon);
})();
</script>
</body>

</html>
11 changes: 10 additions & 1 deletion src/frontend/client/public/assets/bisheng/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ window.BRAND_CONFIG = {
},
URLLoadingIcon: "/assets/bisheng/loading.svg",
loadingIcon: "/assets/bisheng/loading.svg",
loadingAnimation: ""
loadingAnimation: "",
loading: {
icon: {
url: "/assets/bisheng/loading.svg",
relative_path: "",
file_name: "loading.svg"
},
iconOptions: [],
animation: ""
}
};

// Application-wide runtime config (separate from BRAND_CONFIG above).
Expand Down
41 changes: 38 additions & 3 deletions src/frontend/client/src/components/ui/icon/Loading/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
import React, { forwardRef } from "react";
import { cn } from "~/utils";

const ABSOLUTE_URL_PATTERN = /^(https?:|data:|blob:|\/\/)/i;

function withBrandBaseUrl(url = "") {
if (!url) return "";
if (ABSOLUTE_URL_PATTERN.test(url)) return url;

const baseUrl = (__APP_ENV__.BASE_URL || "").replace(/\/$/, "");
if (baseUrl && (url === baseUrl || url.startsWith(`${baseUrl}/`))) return url;

const normalizedUrl = url.startsWith("/") ? url : `/${url}`;
return `${baseUrl}${normalizedUrl}`;
}

function getBrandLoadingIconUrl() {
return withBrandBaseUrl(
window.BRAND_CONFIG?.URLLoadingIcon
|| window.BRAND_CONFIG?.loadingIcon
|| window.BRAND_CONFIG?.loading?.icon?.url
|| "",
);
}

export const LoadingIcon = forwardRef<
SVGSVGElement & { className: any },
(SVGSVGElement | HTMLImageElement) & { className: any },
React.PropsWithChildren<{ className?: string }>
>(({ className, ...props }, ref) => {
Comment on lines 26 to 29
return <svg ref={ref} {...props} className={cn('text-primary', className)} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" width="80" height="80">
const loadingIcon = getBrandLoadingIconUrl();
if (loadingIcon) {
return (
<img
ref={ref as React.ForwardedRef<HTMLImageElement>}
src={loadingIcon}
alt=""
{...props}
className={cn('text-primary max-w-14', className, window.BRAND_CONFIG?.loadingAnimation)}
/>
);
}

return <svg ref={ref as React.ForwardedRef<SVGSVGElement>} {...props} className={cn('text-primary', className)} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" width="80" height="80">
<rect x="10" y="8" width="3" height="2" rx="1" fill="currentColor"></rect>
<rect x="14" y="8" width="10" height="2" rx="1" fill="currentColor">
<animate attributeName="width" values="12;7;12" dur="1s" repeatCount="indefinite" begin="-0.3s" />
Expand All @@ -29,4 +64,4 @@ export const LoadingIcon = forwardRef<
<animate attributeName="x" values="23;20;23" dur="1s" repeatCount="indefinite" begin="-0.1s" />
</rect>
</svg>
});
});
11 changes: 10 additions & 1 deletion src/frontend/platform/public/assets/bisheng/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ window.BRAND_CONFIG = {
},
URLLoadingIcon: "/assets/bisheng/loading.svg",
loadingIcon: "/assets/bisheng/loading.svg",
loadingAnimation: ""
loadingAnimation: "",
loading: {
icon: {
url: "/assets/bisheng/loading.svg",
relative_path: "",
file_name: "loading.svg"
},
iconOptions: [],
animation: ""
}
};

// Application-wide runtime config (separate from BRAND_CONFIG above).
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/platform/public/locales/en-US/bs.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
"brandUrlDialogDescription": "Paste an image URL. It will be added to the selectable asset list.",
"brandUrlDialogCancel": "Cancel",
"brandUrlDialogConfirm": "Confirm",
"brandAssetUrlInvalid": "Enter a valid URL that starts with http:// or https://.",
"brandAssetUrlInvalid": "Enter a complete image URL. It must start with http:// or https:// and cannot contain spaces, <, or >.",
"brandAssetUrlAddSuccess": "URL added",
"brandUrlAssetName": "URL asset",
"brandDefaultLoadingIcon": "Default loading icon",
Expand All @@ -120,6 +120,7 @@
"brandZhPlaceholder": "Chinese",
"brandEnPlaceholder": "English",
"brandSaveSuccess": "Saved",
"brandNameInvalidCharacters": "System brand cannot contain < or >. Remove them before saving.",
"brandFavicon": "Favicon",
"brandFaviconSpec": "32 x 32, ico recommended",
"brandLoginHeroLight": "Login hero",
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/platform/public/locales/ja/bs.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
"brandUrlDialogDescription": "画像 URL を貼り付けると、選択可能な素材リストに追加されます。",
"brandUrlDialogCancel": "キャンセル",
"brandUrlDialogConfirm": "確定",
"brandAssetUrlInvalid": "http:// または https:// で始まる有効な URL を入力してください。",
"brandAssetUrlInvalid": "完全な画像 URL を入力してください。http:// または https:// で始まり、空白や <、> などの文字は使用できません。",
"brandAssetUrlAddSuccess": "URL を追加しました",
"brandUrlAssetName": "URL 素材",
"brandDefaultLoadingIcon": "内蔵デフォルト Loading アイコン",
Expand All @@ -120,6 +120,7 @@
"brandZhPlaceholder": "中国語",
"brandEnPlaceholder": "英語",
"brandSaveSuccess": "保存しました",
"brandNameInvalidCharacters": "システムブランド名に < または > は使用できません。削除してから保存してください。",
"brandFavicon": "ブラウザーアイコン",
"brandFaviconSpec": "32 x 32、ico 推奨",
"brandLoginHeroLight": "ログイン大画像",
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/platform/public/locales/zh-Hans/bs.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
"brandUrlDialogDescription": "粘贴图片 URL,确认后会加入可选择的素材列表。",
"brandUrlDialogCancel": "取消",
"brandUrlDialogConfirm": "确定",
"brandAssetUrlInvalid": "请输入以 http:// 或 https:// 开头的有效 URL。",
"brandAssetUrlInvalid": "请输入完整图片 URL,需以 http:// 或 https:// 开头,且不能包含空格、<、> 等字符。",
"brandAssetUrlAddSuccess": "URL 已添加",
"brandUrlAssetName": "URL 素材",
"brandDefaultLoadingIcon": "内置默认 Loading 图标",
Expand All @@ -121,6 +121,7 @@
"brandZhPlaceholder": "中文",
"brandEnPlaceholder": "英文",
"brandSaveSuccess": "保存成功",
"brandNameInvalidCharacters": "系统品牌名称不能包含 < 或 >,请删除后再保存。",
"brandFavicon": "浏览器标签图标",
"brandFaviconSpec": "32 x 32,建议 ico",
"brandLoginHeroLight": "登录页左侧大图",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ interface BrandAssetUploadProps {
onChange: (asset: BrandAsset) => void;
onPreview?: () => void;
onUploaded?: (asset: BrandAsset) => void;
onDeleted?: (deletedAsset: BrandAssetOption, fallbackAsset: BrandAsset) => void;
onDeleted?: (deletedAsset: BrandAssetOption, fallbackAsset: BrandAsset, deletedWasSelected: boolean) => void;
onUrlAdded?: (asset: BrandAsset) => void;
}

Expand Down Expand Up @@ -100,6 +100,16 @@ function AssetOptionContent({
event.stopPropagation();
};

const handleDeletePointerUp = (event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
};

const handleDeleteMouseUp = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
};

const handleDeleteClick = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -137,7 +147,9 @@ function AssetOptionContent({
title={t("theme.brandDeleteAsset")}
disabled={deleting}
onPointerDown={handleDeletePointerDown}
onPointerUp={handleDeletePointerUp}
onMouseDown={handleDeleteMouseDown}
onMouseUp={handleDeleteMouseUp}
onClick={handleDeleteClick}
>
{deleting ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Expand Down Expand Up @@ -166,6 +178,7 @@ export default function BrandAssetUpload({
const { t } = useTranslation();
const { toast } = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const suppressSelectValueRef = useRef("");
const [uploading, setUploading] = useState(false);
const [deletingValue, setDeletingValue] = useState("");
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
Expand Down Expand Up @@ -219,6 +232,8 @@ export default function BrandAssetUpload({
};

const handleAssetSelect = (assetValue: string) => {
if (suppressSelectValueRef.current === assetValue) return;

const selected = effectiveOptions.find((option) => getOptionValue(option) === assetValue);
if (!selected) return;
onChange({
Expand All @@ -241,12 +256,19 @@ export default function BrandAssetUpload({
if (option.is_default) return;

const optionValue = getOptionValue(option);
suppressSelectValueRef.current = optionValue;
window.setTimeout(() => {
if (suppressSelectValueRef.current === optionValue) {
suppressSelectValueRef.current = "";
}
}, 500);
const deletedWasSelected = optionValue === selectedValue;
if (!option.relative_path) {
const fallbackAsset = getFallbackAsset();
if (optionValue === selectedValue) {
if (deletedWasSelected) {
onChange(fallbackAsset);
}
onDeleted?.(option, fallbackAsset);
onDeleted?.(option, fallbackAsset, deletedWasSelected);
toast({
title: t("prompt"),
variant: "success",
Expand All @@ -269,10 +291,10 @@ export default function BrandAssetUpload({
file_name: defaultAsset.file_name || getFileName(defaultAsset),
};

if (optionValue === selectedValue) {
if (deletedWasSelected) {
onChange(fallbackAsset);
}
onDeleted?.(option, fallbackAsset);
onDeleted?.(option, fallbackAsset, deletedWasSelected);
toast({
title: t("prompt"),
variant: "success",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ const getBrandTitle = (brandName?: BrandText) => {
: (brandName?.en || brandName?.zh || "");
};

const hasInvalidBrandText = (brandName: BrandText) => (
brandName.zh.includes("<")
|| brandName.zh.includes(">")
|| brandName.en.includes("<")
|| brandName.en.includes(">")
);

const omitLinsightAgentName = (config: BrandConfig) => {
const payload = { ...config } as Partial<BrandConfig>;
delete payload.linsightAgentName;
Expand Down Expand Up @@ -282,14 +289,15 @@ export default function BrandCustomization() {
key: BrandAssetKey,
deletedAsset: BrandAssetOption,
fallbackAsset: BrandAsset,
deletedWasSelected: boolean,
) => {
const deletedValue = getAssetValue(deletedAsset);
setAssetOptions((current) => ({
...current,
[key]: (current[key] || []).filter((option) => getAssetValue(option) !== deletedValue),
}));
setConfig((current) => {
if (getAssetValue(current.assets[key]) !== deletedValue) {
if (!deletedWasSelected || getAssetValue(current.assets[key]) !== deletedValue) {
return current;
}
return {
Expand Down Expand Up @@ -380,6 +388,7 @@ export default function BrandCustomization() {
const handleLoadingIconDeleted = (
deletedAsset: BrandAssetOption,
fallbackAsset: BrandAsset,
deletedWasSelected: boolean,
) => {
const deletedValue = getAssetValue(deletedAsset);
if (deletedAsset.relative_path) {
Expand All @@ -390,7 +399,7 @@ export default function BrandCustomization() {

setConfig((current) => {
const currentValue = getAssetValue(current.loading.icon) || current.URLLoadingIcon || "";
const shouldResetCurrent = currentValue === deletedValue;
const shouldResetCurrent = deletedWasSelected && currentValue === deletedValue;
return {
...current,
URLLoadingIcon: shouldResetCurrent ? fallbackAsset.url : current.URLLoadingIcon,
Expand Down Expand Up @@ -420,6 +429,15 @@ export default function BrandCustomization() {
};

const handleSave = async () => {
if (hasInvalidBrandText(config.brandName)) {
toast({
title: t("prompt"),
variant: "warning",
description: t("theme.brandNameInvalidCharacters"),
});
return;
}

setSaving(true);
const saved = await captureAndAlertRequestErrorHoc(saveBrandConfigApi(normalizeForSave(config)));
setSaving(false);
Expand Down Expand Up @@ -491,7 +509,9 @@ export default function BrandCustomization() {
onChange={(asset) => handleAssetChange(key, asset)}
onPreview={() => setPreviewTarget(key)}
onUploaded={(asset) => handleAssetUploaded(key, asset)}
onDeleted={(deletedAsset, fallbackAsset) => handleAssetDeleted(key, deletedAsset, fallbackAsset)}
onDeleted={(deletedAsset, fallbackAsset, deletedWasSelected) => (
handleAssetDeleted(key, deletedAsset, fallbackAsset, deletedWasSelected)
)}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,6 @@ function LoginPreview({
className="h-10 max-w-[170px] object-contain"
/>
</Highlight>
<Highlight active={target === "brandName"}>
<p className="text-center text-sm text-muted-foreground">{title}</p>
</Highlight>
<div className="space-y-3">
<SkeletonLine className="h-9" />
<SkeletonLine className="h-9" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ export const DEFAULT_BRAND_CONFIG: BrandConfig = {
},
},
loading: {
icon: null,
icon: {
url: "/assets/bisheng/loading.svg",
relative_path: "",
file_name: "loading.svg",
},
iconOptions: [],
animation: "",
},
URLLoadingIcon: "",
URLLoadingIcon: "/assets/bisheng/loading.svg",
};

export const cloneBrandConfig = (config: BrandConfig): BrandConfig => (
Expand Down