feat: add page API RTK Query endpoints and update GuestBlankPlaceholder; bump version to 0.9.91

This commit is contained in:
haorwen
2026-06-26 09:36:01 +08:00
parent a43e7c881d
commit 0f1bbf852c
5 changed files with 563 additions and 225 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vocechat-web",
"version": "0.9.90",
"version": "0.9.91",
"homepage": "https://voce.chat",
"dependencies": {
"@metamask/onboarding": "^1.0.1",
+1
View File
@@ -57,6 +57,7 @@ const whiteList404 = [
"deleteMessage",
"deleteMessages",
"getWidgetExtCSS",
"getPageHtml",
];
const baseQuery = fetchBaseQuery({
baseUrl: BASE_URL,
+33
View File
@@ -497,6 +497,33 @@ export const serverApi = createApi({
getAutoTunnelInfo: builder.query<AutoTunnelInfo, void>({
query: () => ({ url: `/admin/cloudflared/auto_info` }),
}),
getPageApiKey: builder.query<{ key: string }, void>({
query: () => ({ url: `/page/apikey` }),
keepUnusedDataFor: 0,
}),
generatePageApiKey: builder.mutation<{ key: string }, void>({
query: () => ({ url: `/page/apikey`, method: "POST" }),
}),
getPageHtml: builder.query<string, "landing" | "after_signin">({
query: (page) => ({
url: `/page/${page}`,
responseHandler: "text",
}),
}),
uploadPageHtml: builder.mutation<void, { page: "landing" | "after_signin"; html: string }>({
query: ({ page, html }) => ({
url: `/page/${page}`,
method: "PUT",
headers: { "content-type": "text/plain" },
body: html,
}),
}),
resetPageHtml: builder.mutation<void, "landing" | "after_signin">({
query: (page) => ({
url: `/page/${page}`,
method: "DELETE",
}),
}),
}),
});
@@ -562,4 +589,10 @@ export const {
useStopCloudflaredMutation,
useGetAutoTunnelInfoQuery,
useLazyGetAutoTunnelInfoQuery,
useGetPageApiKeyQuery,
useGeneratePageApiKeyMutation,
useGetPageHtmlQuery,
useLazyGetPageHtmlQuery,
useUploadPageHtmlMutation,
useResetPageHtmlMutation,
} = serverApi;
+433 -197
View File
@@ -1,236 +1,472 @@
import { FC, useMemo, useState } from "react";
import { FC, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, useNavigate } from "react-router-dom";
import Linkify from "linkify-react";
import { shallowEqual } from "react-redux";
import { useAppSelector } from "@/app/store";
import IconEdit from "@/assets/icons/edit.svg";
import { KEY_ADMIN_ONLY_INVITE } from "../app/config";
import { BASE_ORIGIN } from "../app/config";
import { compareVersion } from "../utils";
import { useLazyGetPageHtmlQuery, useUploadPageHtmlMutation, useResetPageHtmlMutation, useGeneratePageApiKeyMutation } from "@/app/services/server";
import useServerExtSetting from "../hooks/useServerExtSetting";
import useLicense from "@/hooks/useLicense";
import ChannelModal from "./ChannelModal";
import InviteModal from "./InviteModal";
import UsersModal from "./UsersModal";
import EditIcon from "@/assets/icons/edit.svg";
import IconChat from "@/assets/icons/placeholder.chat.svg";
import IconDownload from "@/assets/icons/placeholder.download.svg";
import IconInvite from "@/assets/icons/placeholder.invite.svg";
import IconAsk from "@/assets/icons/placeholder.question.svg";
import ChannelModal from "./ChannelModal";
import InviteModal from "./InviteModal";
import UsersModal from "./UsersModal";
import { shallowEqual } from "react-redux";
import { KEY_ADMIN_ONLY_INVITE } from "../app/config";
import useServerExtSetting from "../hooks/useServerExtSetting";
import useLicense from "@/hooks/useLicense";
import { compareVersion } from "@/utils";
import CloseIcon from "@/assets/icons/close.svg";
import DownloadIcon from "@/assets/icons/download.svg";
import CopyIcon from "@/assets/icons/copy.svg";
import CheckIcon from "@/assets/icons/check.sign.svg";
const IFRAME_MIN_VERSION = "0.5.21";
type PageType = "landing" | "after_signin";
interface Props {
type?: "chat" | "user";
}
const classes = {
const legacyClasses = {
box: "w-[220px] md:w-[200px] h-[100px] md:h-[200px] cursor-pointer bg-gray-50 dark:bg-gray-800 rounded-3xl flex-center flex-col gap-4",
boxIcon: "w-7 h-7 md:w-10 md:h-10",
boxTip: "px-5 text-xs md:text-sm text-slate-600 dark:text-gray-100 font-bold text-center",
};
const BlankPlaceholder: FC<Props> = ({ type = "chat" }) => {
const navigate = useNavigate();
const { t } = useTranslation("welcome");
const iframeRef = useRef<HTMLIFrameElement>(null);
const { getExtSetting } = useServerExtSetting();
const onlyAdminCanInvite = getExtSetting(KEY_ADMIN_ONLY_INVITE);
const { t } = useTranslation("welcome");
const server = useAppSelector((store) => store.server, shallowEqual);
const isAdmin = useAppSelector((store) => store.authData.user?.is_admin, shallowEqual);
const navigate = useNavigate();
const useIframe = compareVersion(server.version, IFRAME_MIN_VERSION) >= 0;
const { license: licenseInfo } = useLicense(true);
const showVoceSpace = useMemo(() => compareVersion(server.version, "0.5.6") >= 0, [server.version]);
const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [createChannelVisible, setCreateChannelVisible] = useState(false);
const [userListVisible, setUserListVisible] = useState(false);
const toggleChannelModalVisible = () => {
setCreateChannelVisible((prev) => !prev);
};
const toggleUserListVisible = () => {
setUserListVisible((prev) => !prev);
};
const toggleInviteModalVisible = () => {
setInviteModalVisible((prev) => !prev);
};
const chatTip = type == "chat" ? t("start_by_channel") : t("start_by_dm");
const chatHandler = type == "chat" ? toggleChannelModalVisible : toggleUserListVisible;
const [editorModalVisible, setEditorModalVisible] = useState(false);
const [previewPage, setPreviewPage] = useState<PageType>("after_signin");
const [cacheBust, setCacheBust] = useState(0);
const [htmlPreview, setHtmlPreview] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { license: licenseInfo } = useLicense(true);
const [fetchPageHtml] = useLazyGetPageHtmlQuery();
const [uploadPageHtml, { isLoading: uploading }] = useUploadPageHtmlMutation();
const [resetPageHtml, { isLoading: resetting }] = useResetPageHtmlMutation();
const [generatePageApiKey] = useGeneratePageApiKeyMutation();
const toLicensePage = () => {
navigate("/setting/license");
const canInvite = isAdmin || !onlyAdminCanInvite;
const chatLabel = type === "chat" ? t("start_by_channel") : t("start_by_dm");
const chatAction = type === "chat" ? "create_channel" : "start_dm";
const sendServerInfo = () => {
iframeRef.current?.contentWindow?.postMessage(
{
type: "server_info",
name: server.name,
description: server.description || t("desc"),
canInvite,
labels: {
invite: t("invite"),
channel: chatLabel,
download: t("download"),
help: t("help"),
},
chatAction,
},
"*"
);
};
const currentVersion = useAppSelector((store) => store.server.version, shallowEqual);
const showVoceSpace = useMemo(
() => compareVersion(currentVersion, "0.5.6") >= 0,
[currentVersion]
);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (!e.data || typeof e.data !== "object") return;
if (e.data.type === "iframe_ready" && e.data.page === "signed_in") {
sendServerInfo();
}
if (e.data.type === "action") {
switch (e.data.action) {
case "invite":
setInviteModalVisible(true);
break;
case "create_channel":
setCreateChannelVisible(true);
break;
case "start_dm":
setUserListVisible(true);
break;
}
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [server, canInvite, type, t]);
useEffect(() => {
sendServerInfo();
}, [server, canInvite, type]);
useEffect(() => {
if (!editorModalVisible) return;
setHtmlPreview(null);
fetchPageHtml(previewPage).unwrap().then((html) => {
setHtmlPreview(html);
}).catch(() => {
setHtmlPreview("");
});
}, [editorModalVisible, previewPage]);
const handleDownload = async () => {
try {
const html = await fetchPageHtml(previewPage).unwrap();
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = previewPage === "landing" ? "landing.html" : "after-signin.html";
a.click();
URL.revokeObjectURL(url);
} catch {
// fetch failed, nothing to download
}
};
const handleReset = async () => {
await resetPageHtml(previewPage);
setCacheBust(Date.now());
fetchPageHtml(previewPage).unwrap().then((html) => {
setHtmlPreview(html);
}).catch(() => {
setHtmlPreview("");
});
};
const handleUpload = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".html,text/html";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const html = await file.text();
await uploadPageHtml({ page: previewPage, html });
setCacheBust(Date.now());
setHtmlPreview(html);
};
input.click();
};
const handleCopyPrompt = async () => {
try {
const { key } = await generatePageApiKey().unwrap();
const prompt = `# Role
You are an experienced front-end development expert and UI/UX designer. Your ultimate task is to help me write a high-performance, modern, single-file landing page.
# ⚙️ Deployment Context & API Spec
Please note that after you generate the landing page code, it will be deployed using the following APIs (both of them are required):
1. **Upload Landing Page**: Deploy your generated HTML to the server via the following APIs (both of them):
curl -X PUT ${BASE_ORIGIN}/api/page/landing \
-H "X-Page-Api-Key: ${key}" \
-H "Content-Type: text/plain" \
--data-binary @landing.html
curl -X PUT ${BASE_ORIGIN}/api/page/after_signin \
-H "X-Page-Api-Key: ${key}" \
-H "Content-Type: text/plain" \
--data-binary @landing.html
2. **Retrieve Landing Page for Verification**: GET https://<replace_with_current_address>/api/page/landing
*Since the upload is a binary transfer in text/plain format, the code you output subsequently must be fully self-contained, lossless, and without any third-party placeholders, allowing upstream tools (Agent/Tool) to directly extract and package it.*
---
# ⚠️ Important Instruction: Do not write code immediately!
To ensure the landing page aligns with my business requirements, you must understand my specific needs before you start writing code. Please **immediately ask me 4-5 key questions at once**, and use the language of my response for subsequent interactions.
---
# 🛠️ Final Technical Specifications (for your reference when generating code later)
Once I answer your questions, you must strictly adhere to the following requirements when generating the HTML code:
1. **Single-file self-contained**: All HTML structure, CSS styles (must use Tailwind CSS CDN), and JavaScript logic must be written in a single landing.html file.
2. **No local dependencies**: No local relative path resources are allowed. Images must use royalty-free, high-definition image links from Unsplash or similar sources. Icons must import CDN resources from FontAwesome or Lucide.
3. **Responsive design**: Well-adapted for both Mobile and Desktop to support mobile conversion rates.
4. **Complete output**: Please avoid shortcuts; you must output the complete code. Placeholders such as <!-- remaining code here --> are not allowed.
# Next Step
Please start asking me questions now and wait for my detailed response.
`;
await navigator.clipboard.writeText(prompt);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
const iframeSrc = `${BASE_ORIGIN}/api/page/${previewPage}${cacheBust ? `?t=${cacheBust}` : ""}`;
const htmlLines = (htmlPreview ?? "").split("\n");
const pageLabel = previewPage === "after_signin" ? "已登录用户" : "未登录用户";
if (!useIframe) {
const chatTip = type === "chat" ? t("start_by_channel") : t("start_by_dm");
const chatHandler = type === "chat" ? () => setCreateChannelVisible(true) : () => setUserListVisible(true);
const toLicensePage = () => navigate("/setting/license");
return (
<>
<div className="flex flex-col gap-8 -mt-[50px] dark:bg-gray-700">
<div className="flex flex-col gap-2 items-center group px-4">
<h2 className="text-center text-3xl text-slate-700 dark:text-white font-bold">
{t("title", { name: server.name })}
</h2>
<p className="text-sm text-gray-400 max-w-md text-center relative whitespace-normal">
<Linkify
options={{
render: {
url: ({ content, attributes: { href: link } }) => (
<a className="text-primary-400" target="_blank" href={link} rel="noreferrer">
{content}
</a>
),
},
}}
>
{server.description ? server.description : t("desc")}
</Linkify>
{isAdmin && (
<NavLink to={"/setting/overview"} className="absolute top-0 -right-7 invisible group-hover:visible">
<EditIcon />
</NavLink>
)}
</p>
</div>
<div style={{ width: "432px", display: "flex", flexWrap: "wrap", gap: "16px", justifyContent: "center" }}>
{(isAdmin || !onlyAdminCanInvite) && (
<button className={legacyClasses.box} onClick={() => setInviteModalVisible(true)}>
<IconInvite className={legacyClasses.boxIcon} />
<div className={legacyClasses.boxTip}>{t("invite")}</div>
</button>
)}
{licenseInfo?.user_limit == 20 && showVoceSpace ? (
<button onClick={toLicensePage} className={legacyClasses.box}>
<svg viewBox="0 0 1028 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="39.5" height="39">
<path d="M625.669087 392.461597l-47.150172-6.847535c-6.651891 0-13.499427-6.651891-16.825373-13.499427L538.020634 324.964463c-6.651891-10.173481-13.499427-16.825373-23.672908-16.825372s-16.825373 6.651891-20.151318 13.499426l-23.672908 47.150172c-3.325946 6.651891-10.173481 10.173481-16.825372 13.499427l-47.150172 6.651892c-10.173481 0-16.825373 6.847535-20.151319 16.825372-3.325946 10.173481 0 16.825373 6.651892 23.672908l37.172335 40.498281c3.325946 3.325946 6.651891 10.173481 6.651891 13.499426v3.325946l-10.173481 57.323653c0 10.173481 3.325946 16.825373 10.173481 23.672908 6.651891 6.651891 16.825373 6.651891 26.998854 0l40.49828-23.672908h10.173481c3.325946 0 6.847535 0 10.173481 3.325946l43.824227 23.672908c3.325946 3.325946 6.651891 3.325946 10.173481 3.325946 3.325946 0 10.173481 0 13.499426-3.325946 6.847535-6.651891 10.173481-13.499427 10.173482-23.672908l-10.173482-53.997707v-3.325946c0-6.651891 3.325946-10.173481 6.847536-16.825373l37.172335-40.49828c6.651891-6.651891 6.651891-16.825373 6.651891-23.672908-0.391288-9.977837-10.564769-16.629729-17.21666-16.629729z" fill="#FFDB5D"></path>
<path d="M512 0C229.294612 0 0 229.294612 0 512s229.294612 512 512 512 512-229.294612 512-512S794.901032 0 512 0z m-116.212457 798.618265s-33.650745-37.172335-63.975545-63.975544c-43.824226-13.499427-97.821934-23.672908-97.821933-23.672908l67.497134-114.647306c33.650745 47.150172 97.821934 84.322507 155.145587 97.821933l-60.845243 104.473825z m-97.821934-350.59381c0-114.647306 101.147879-222.447077 215.795186-222.447076 114.647306 0 215.795185 101.147879 215.795185 215.795185 0 114.647306-101.147879 215.795185-215.795185 215.795185-114.647306-0.195644-215.795185-94.495988-215.795186-209.143294z m401.069928 279.77073c-30.324799 30.324799-67.497134 70.82308-67.497134 70.82308l-60.649599-104.473825c57.323653-13.499427 114.647306-53.997707 148.298051-97.821933l70.82308 114.647306c0.195644-3.325946-47.150172 3.325946-90.974398 16.825372z" fill="#FFDB5D"></path>
</svg>
<div className={legacyClasses.boxTip}>{t("license")}</div>
</button>
) : (
<button onClick={chatHandler} className={legacyClasses.box}>
<IconChat className={legacyClasses.boxIcon} />
<div className={legacyClasses.boxTip}>{chatTip}</div>
</button>
)}
{showVoceSpace ? (
<NavLink to={"/setting/video"} className={legacyClasses.box}>
<svg width="39" height="39" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M29.2884 20.4182C29.7499 19.0293 29.9998 17.5438 29.9998 16C29.9998 8.26806 23.7319 2.00008 15.9999 2.00008C8.26798 2.00008 2 8.26806 2 16C2 17.5744 2.25987 19.088 2.73906 20.5005C3.09861 20.5088 3.43836 20.5246 3.74422 20.539C4.1999 20.5603 4.55678 20.5768 4.8465 20.571L4.85764 20.5707L4.9685 18.3288L4.96959 18.3067L4.97155 18.2847L5.09066 16.9465L5.0947 16.9011L5.1024 16.8562L5.23684 16.0725C5.376 15.2613 5.76653 14.5144 6.35326 13.9372C6.851 13.4475 7.44688 13.1183 8.07693 12.9487C7.43026 12.6087 6.98926 11.9304 6.98926 11.1491C6.98926 10.027 7.8989 9.11737 9.021 9.11737C10.1431 9.11737 11.0527 10.027 11.0527 11.1491C11.0527 11.9091 10.6355 12.5717 10.0175 12.9201C10.5584 13.0484 11.0773 13.2921 11.5345 13.6506C11.7371 13.3464 11.9714 13.0622 12.2347 12.8031C13.0209 12.0296 13.9873 11.55 14.9979 11.3626C14.1354 10.9827 13.5332 10.1204 13.5332 9.11741C13.5332 7.76315 14.631 6.6653 15.9853 6.6653C17.3396 6.6653 18.4374 7.76315 18.4374 9.11741C18.4374 10.1214 17.834 10.9845 16.9701 11.3638C17.9716 11.5504 18.9301 12.0227 19.7133 12.7789C19.9844 13.0407 20.2261 13.3293 20.4354 13.6391C20.8836 13.2519 21.3992 12.9818 21.9412 12.8296C21.3295 12.4794 20.9173 11.8204 20.9173 11.065C20.9173 9.94295 21.827 9.0333 22.9491 9.0333C24.0712 9.0333 24.9808 9.94295 24.9808 11.065C24.9808 11.8041 24.5862 12.4511 23.9961 12.8066C24.6416 12.9728 25.2529 13.3056 25.7617 13.8061C26.3485 14.3833 26.739 15.1303 26.8782 15.9415L27.0126 16.7252L27.0206 16.7717L27.0246 16.8187L27.1858 18.6893L27.1866 18.6993L27.1873 18.7094L27.3044 20.4304C27.337 20.4326 27.3723 20.4346 27.4104 20.4364C27.7961 20.4543 28.2307 20.4435 28.7617 20.4304C28.9274 20.4263 29.1025 20.422 29.2884 20.4182ZM28.3028 22.6866C27.9281 22.6925 27.586 22.6933 27.3064 22.6803C26.9334 22.663 26.476 22.6195 26.0838 22.4527C25.8728 22.3629 25.6172 22.2115 25.4138 21.9497C25.2432 21.7301 25.1474 21.4816 25.1142 21.2295L25.1073 21.23L24.9468 18.872L24.7906 17.0585L24.6642 16.3213C24.6046 15.9742 24.4375 15.6545 24.1864 15.4075C23.5405 14.7721 22.4925 14.7669 21.833 15.4036C21.6016 15.6271 21.4381 15.9122 21.3619 16.2213L21.4801 16.916L21.4824 16.93L21.4845 16.9441L21.7168 18.5553L21.7173 18.5587L21.8892 19.7777L21.9002 19.8558V19.9346V21.1257H21.9004C21.9004 21.6008 21.7303 22.0667 21.3626 22.4051C21.0107 22.7289 20.5693 22.8509 20.1747 22.8361C19.3976 22.807 18.5481 22.2065 18.5333 21.1537H18.5331V19.9907V19.9124L18.5439 19.8348L18.7541 18.3355L18.7541 18.3355L18.7553 18.327L18.9865 16.7647L18.9883 16.7527L18.9903 16.7407L19.081 16.2079L19.0664 16.122C18.9549 15.4666 18.6331 14.8586 18.1529 14.3948C16.9386 13.2223 15.0059 13.2279 13.8102 14.4043C13.3861 14.8216 13.0918 15.3511 12.9606 15.9291C12.9724 15.9842 12.9831 16.0396 12.9926 16.0953L13.1246 16.8717L13.1314 16.9113L13.1353 16.9513L13.2876 18.5165L13.4604 19.8436L13.4698 19.9158V19.9886V21.2518H13.4722C13.4722 21.7247 13.3041 22.1899 12.938 22.5288C12.587 22.8538 12.1455 22.9772 11.7497 22.9623C10.968 22.9328 10.1268 22.3267 10.1121 21.2798H10.1047V19.9486V19.8724L10.115 19.7969L10.2831 18.5639L10.2831 18.5638L10.5057 16.9316L10.5083 16.9124L10.5115 16.8934L10.6538 16.0641C10.5653 15.867 10.4393 15.6866 10.2819 15.5347C9.62251 14.898 8.57449 14.9031 7.92862 15.5385C7.67752 15.7855 7.51039 16.1052 7.45084 16.4524L7.32411 17.1911L7.21101 18.4618L7.08736 20.9622V21.2427H7.07764C7.06269 21.5177 6.97921 21.8038 6.79469 22.0616C6.59679 22.3381 6.33952 22.5026 6.11592 22.6007C5.70919 22.7791 5.24297 22.8098 4.89175 22.8169C4.54388 22.8239 4.14601 22.8065 3.75287 22.7882C6.14194 27.0893 10.731 29.9999 15.9999 29.9999C21.3102 29.9999 25.9299 27.0434 28.3028 22.6866ZM15.9853 8.81264C15.817 8.81264 15.6805 8.94909 15.6805 9.11741C15.6805 9.28573 15.817 9.42218 15.9853 9.42218C16.1536 9.42218 16.2901 9.28573 16.2901 9.11741C16.2901 8.94909 16.1536 8.81264 15.9853 8.81264ZM22.9491 10.8096C22.808 10.8096 22.6936 10.924 22.6936 11.065C22.6936 11.2061 22.808 11.3205 22.9491 11.3205C23.0901 11.3205 23.2045 11.2061 23.2045 11.065C23.2045 10.924 23.0901 10.8096 22.9491 10.8096ZM27.3471 21.0237H27.3448L27.3369 20.9082C27.3439 20.9486 27.3471 20.9875 27.3471 21.0237ZM4.84102 21.1507V21.0613C4.83587 21.1 4.83379 21.1247 4.83379 21.1507H4.84102ZM8.76556 11.1491C8.76556 11.008 8.87993 10.8937 9.021 10.8937C9.16208 10.8937 9.27644 11.008 9.27644 11.1491C9.27644 11.2902 9.16208 11.4046 9.021 11.4046C8.87993 11.4046 8.76556 11.2902 8.76556 11.1491Z" fill="url(#paint0_linear_1_116)" />
<defs>
<linearGradient id="paint0_linear_1_116" x1="7.69527" y1="22.0583" x2="21.1597" y2="11.4392" gradientUnits="userSpaceOnUse">
<stop stopColor="#056CF2" />
<stop offset="1" stopColor="#4ECCC6" />
</linearGradient>
</defs>
</svg>
<div className={legacyClasses.boxTip}>{t("vocespace")}</div>
</NavLink>
) : (
<a href={"https://voce.chat#download"} target={"_blank"} rel="noreferrer" className={legacyClasses.box}>
<IconDownload className={legacyClasses.boxIcon} />
<div className={legacyClasses.boxTip}>{t("download")}</div>
</a>
)}
<a href={"https://doc.voce.chat"} target={"_blank"} rel="noreferrer" className={legacyClasses.box}>
<IconAsk className={legacyClasses.boxIcon} />
<div className={legacyClasses.boxTip}>{t("help")}</div>
</a>
</div>
</div>
{createChannelVisible && <ChannelModal personal={true} closeModal={() => setCreateChannelVisible(false)} />}
{userListVisible && <UsersModal closeModal={() => setUserListVisible(false)} />}
{inviteModalVisible && <InviteModal closeModal={() => setInviteModalVisible(false)} />}
</>
);
}
return (
<>
<div className="flex flex-col gap-8 -mt-[50px] dark:bg-gray-700">
<div className="flex flex-col gap-2 items-center group px-4">
<h2 className="text-center text-3xl text-slate-700 dark:text-white font-bold">
{t("title", { name: server.name })}
</h2>
<p className="text-sm text-gray-400 max-w-md text-center relative whitespace-normal">
<Linkify
options={{
render: {
url: ({ content, attributes: { href: link } }) => {
return (
<>
<a
className="text-primary-400"
target="_blank"
href={link}
rel="noreferrer"
>
{content}
</a>
</>
);
},
},
}}
<div className="relative w-full h-full">
<iframe
ref={iframeRef}
src={iframeSrc}
className="w-full h-full border-0"
title="Welcome Page"
/>
{isAdmin && (
<div className="absolute top-3 right-3 z-10">
<button
onClick={() => setEditorModalVisible(true)}
className="opacity-30 hover:opacity-80 transition-opacity p-1 rounded"
title="Edit page"
>
{server.description ? server.description : t("desc")}
</Linkify>
{isAdmin && (
<NavLink
to={"/setting/overview"}
className="absolute top-0 -right-7 invisible group-hover:visible"
>
<IconEdit />
</NavLink>
)}
</p>
</div>
<div
style={{
width: "432px",
display: "flex",
flexWrap: "wrap",
gap: "16px",
justifyContent: "center",
}}
>
{(isAdmin || !onlyAdminCanInvite) && (
<button className={classes.box} onClick={toggleInviteModalVisible}>
<IconInvite className={classes.boxIcon} />
<div className={classes.boxTip}>{t("invite")}</div>
<EditIcon className="w-5 h-5 dark:fill-white" />
</button>
)}
{licenseInfo?.user_limit == 20 && showVoceSpace ? (
<button onClick={toLicensePage} className={classes.box}>
<svg
viewBox="0 0 1028 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="5677"
xmlnsXlink="http://www.w3.org/1999/xlink"
width="39.5"
height="39"
>
<path
d="M625.669087 392.461597l-47.150172-6.847535c-6.651891 0-13.499427-6.651891-16.825373-13.499427L538.020634 324.964463c-6.651891-10.173481-13.499427-16.825373-23.672908-16.825372s-16.825373 6.651891-20.151318 13.499426l-23.672908 47.150172c-3.325946 6.651891-10.173481 10.173481-16.825372 13.499427l-47.150172 6.651892c-10.173481 0-16.825373 6.847535-20.151319 16.825372-3.325946 10.173481 0 16.825373 6.651892 23.672908l37.172335 40.498281c3.325946 3.325946 6.651891 10.173481 6.651891 13.499426v3.325946l-10.173481 57.323653c0 10.173481 3.325946 16.825373 10.173481 23.672908 6.651891 6.651891 16.825373 6.651891 26.998854 0l40.49828-23.672908h10.173481c3.325946 0 6.847535 0 10.173481 3.325946l43.824227 23.672908c3.325946 3.325946 6.651891 3.325946 10.173481 3.325946 3.325946 0 10.173481 0 13.499426-3.325946 6.847535-6.651891 10.173481-13.499427 10.173482-23.672908l-10.173482-53.997707v-3.325946c0-6.651891 3.325946-10.173481 6.847536-16.825373l37.172335-40.49828c6.651891-6.651891 6.651891-16.825373 6.651891-23.672908-0.391288-9.977837-10.564769-16.629729-17.21666-16.629729z"
fill="#FFDB5D"
p-id="5678"
data-spm-anchor-id="a313x.search_index.0.i1.55193a81XeGIRV"
></path>
<path
d="M512 0C229.294612 0 0 229.294612 0 512s229.294612 512 512 512 512-229.294612 512-512S794.901032 0 512 0z m-116.212457 798.618265s-33.650745-37.172335-63.975545-63.975544c-43.824226-13.499427-97.821934-23.672908-97.821933-23.672908l67.497134-114.647306c33.650745 47.150172 97.821934 84.322507 155.145587 97.821933l-60.845243 104.473825z m-97.821934-350.59381c0-114.647306 101.147879-222.447077 215.795186-222.447076 114.647306 0 215.795185 101.147879 215.795185 215.795185 0 114.647306-101.147879 215.795185-215.795185 215.795185-114.647306-0.195644-215.795185-94.495988-215.795186-209.143294z m401.069928 279.77073c-30.324799 30.324799-67.497134 70.82308-67.497134 70.82308l-60.649599-104.473825c57.323653-13.499427 114.647306-53.997707 148.298051-97.821933l70.82308 114.647306c0.195644-3.325946-47.150172 3.325946-90.974398 16.825372z"
fill="#FFDB5D"
p-id="5679"
data-spm-anchor-id="a313x.search_index.0.i0.55193a81XeGIRV"
></path>
</svg>
<div className={classes.boxTip}>{t("license")}</div>
</button>
) : (
<button onClick={chatHandler} className={classes.box}>
<IconChat className={classes.boxIcon} />
<div className={classes.boxTip}>{chatTip}</div>
</button>
)}
{/* Vocespace nav to */}
{showVoceSpace ? (
<NavLink to={"/setting/video"} className={classes.box}>
<svg
width="39"
height="39"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M29.2884 20.4182C29.7499 19.0293 29.9998 17.5438 29.9998 16C29.9998 8.26806 23.7319 2.00008 15.9999 2.00008C8.26798 2.00008 2 8.26806 2 16C2 17.5744 2.25987 19.088 2.73906 20.5005C3.09861 20.5088 3.43836 20.5246 3.74422 20.539C4.1999 20.5603 4.55678 20.5768 4.8465 20.571L4.85764 20.5707L4.9685 18.3288L4.96959 18.3067L4.97155 18.2847L5.09066 16.9465L5.0947 16.9011L5.1024 16.8562L5.23684 16.0725C5.376 15.2613 5.76653 14.5144 6.35326 13.9372C6.851 13.4475 7.44688 13.1183 8.07693 12.9487C7.43026 12.6087 6.98926 11.9304 6.98926 11.1491C6.98926 10.027 7.8989 9.11737 9.021 9.11737C10.1431 9.11737 11.0527 10.027 11.0527 11.1491C11.0527 11.9091 10.6355 12.5717 10.0175 12.9201C10.5584 13.0484 11.0773 13.2921 11.5345 13.6506C11.7371 13.3464 11.9714 13.0622 12.2347 12.8031C13.0209 12.0296 13.9873 11.55 14.9979 11.3626C14.1354 10.9827 13.5332 10.1204 13.5332 9.11741C13.5332 7.76315 14.631 6.6653 15.9853 6.6653C17.3396 6.6653 18.4374 7.76315 18.4374 9.11741C18.4374 10.1214 17.834 10.9845 16.9701 11.3638C17.9716 11.5504 18.9301 12.0227 19.7133 12.7789C19.9844 13.0407 20.2261 13.3293 20.4354 13.6391C20.8836 13.2519 21.3992 12.9818 21.9412 12.8296C21.3295 12.4794 20.9173 11.8204 20.9173 11.065C20.9173 9.94295 21.827 9.0333 22.9491 9.0333C24.0712 9.0333 24.9808 9.94295 24.9808 11.065C24.9808 11.8041 24.5862 12.4511 23.9961 12.8066C24.6416 12.9728 25.2529 13.3056 25.7617 13.8061C26.3485 14.3833 26.739 15.1303 26.8782 15.9415L27.0126 16.7252L27.0206 16.7717L27.0246 16.8187L27.1858 18.6893L27.1866 18.6993L27.1873 18.7094L27.3044 20.4304C27.337 20.4326 27.3723 20.4346 27.4104 20.4364C27.7961 20.4543 28.2307 20.4435 28.7617 20.4304C28.9274 20.4263 29.1025 20.422 29.2884 20.4182ZM28.3028 22.6866C27.9281 22.6925 27.586 22.6933 27.3064 22.6803C26.9334 22.663 26.476 22.6195 26.0838 22.4527C25.8728 22.3629 25.6172 22.2115 25.4138 21.9497C25.2432 21.7301 25.1474 21.4816 25.1142 21.2295L25.1073 21.23L24.9468 18.872L24.7906 17.0585L24.6642 16.3213C24.6046 15.9742 24.4375 15.6545 24.1864 15.4075C23.5405 14.7721 22.4925 14.7669 21.833 15.4036C21.6016 15.6271 21.4381 15.9122 21.3619 16.2213L21.4801 16.916L21.4824 16.93L21.4845 16.9441L21.7168 18.5553L21.7173 18.5587L21.8892 19.7777L21.9002 19.8558V19.9346V21.1257H21.9004C21.9004 21.6008 21.7303 22.0667 21.3626 22.4051C21.0107 22.7289 20.5693 22.8509 20.1747 22.8361C19.3976 22.807 18.5481 22.2065 18.5333 21.1537H18.5331V19.9907V19.9124L18.5439 19.8348L18.7541 18.3355L18.7541 18.3355L18.7553 18.327L18.9865 16.7647L18.9883 16.7527L18.9903 16.7407L19.081 16.2079L19.0664 16.122C18.9549 15.4666 18.6331 14.8586 18.1529 14.3948C16.9386 13.2223 15.0059 13.2279 13.8102 14.4043C13.3861 14.8216 13.0918 15.3511 12.9606 15.9291C12.9724 15.9842 12.9831 16.0396 12.9926 16.0953L13.1246 16.8717L13.1314 16.9113L13.1353 16.9513L13.2876 18.5165L13.4604 19.8436L13.4698 19.9158V19.9886V21.2518H13.4722C13.4722 21.7247 13.3041 22.1899 12.938 22.5288C12.587 22.8538 12.1455 22.9772 11.7497 22.9623C10.968 22.9328 10.1268 22.3267 10.1121 21.2798H10.1047V19.9486V19.8724L10.115 19.7969L10.2831 18.5639L10.2831 18.5638L10.5057 16.9316L10.5083 16.9124L10.5115 16.8934L10.6538 16.0641C10.5653 15.867 10.4393 15.6866 10.2819 15.5347C9.62251 14.898 8.57449 14.9031 7.92862 15.5385C7.67752 15.7855 7.51039 16.1052 7.45084 16.4524L7.32411 17.1911L7.21101 18.4618L7.08736 20.9622V21.2427H7.07764C7.06269 21.5177 6.97921 21.8038 6.79469 22.0616C6.59679 22.3381 6.33952 22.5026 6.11592 22.6007C5.70919 22.7791 5.24297 22.8098 4.89175 22.8169C4.54388 22.8239 4.14601 22.8065 3.75287 22.7882C6.14194 27.0893 10.731 29.9999 15.9999 29.9999C21.3102 29.9999 25.9299 27.0434 28.3028 22.6866ZM15.9853 8.81264C15.817 8.81264 15.6805 8.94909 15.6805 9.11741C15.6805 9.28573 15.817 9.42218 15.9853 9.42218C16.1536 9.42218 16.2901 9.28573 16.2901 9.11741C16.2901 8.94909 16.1536 8.81264 15.9853 8.81264ZM22.9491 10.8096C22.808 10.8096 22.6936 10.924 22.6936 11.065C22.6936 11.2061 22.808 11.3205 22.9491 11.3205C23.0901 11.3205 23.2045 11.2061 23.2045 11.065C23.2045 10.924 23.0901 10.8096 22.9491 10.8096ZM27.3471 21.0237H27.3448L27.3369 20.9082C27.3439 20.9486 27.3471 20.9875 27.3471 21.0237ZM4.84102 21.1507V21.0613C4.83587 21.0944 4.83379 21.1247 4.83379 21.1507H4.84102ZM8.76556 11.1491C8.76556 11.008 8.87993 10.8937 9.021 10.8937C9.16208 10.8937 9.27644 11.008 9.27644 11.1491C9.27644 11.2902 9.16208 11.4046 9.021 11.4046C8.87993 11.4046 8.76556 11.2902 8.76556 11.1491Z"
fill="url(#paint0_linear_1_116)"
/>
<defs>
<linearGradient
id="paint0_linear_1_116"
x1="7.69527"
y1="22.0583"
x2="21.1597"
y2="11.4392"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#056CF2" />
<stop offset="1" stopColor="#4ECCC6" />
</linearGradient>
</defs>
</svg>
<div className={classes.boxTip}>{t("vocespace")}</div>
</NavLink>
) : (
<a
href={"https://voce.chat#download"}
target={"_blank"}
rel="noreferrer"
className={classes.box}
>
<IconDownload className={classes.boxIcon} />
<div className={classes.boxTip}>{t("download")}</div>
</a>
)}
{/* {!server.upgraded && (
<>
<a
href={"https://voce.chat#download"}
target={"_blank"}
rel="noreferrer"
className={classes.box}
>
<IconDownload className={classes.boxIcon} />
<div className={classes.boxTip}>{t("download")}</div>
</a>
<a
href={"https://doc.voce.chat"}
target={"_blank"}
rel="noreferrer"
className={classes.box}
>
<IconAsk className={classes.boxIcon} />
<div className={classes.boxTip}>{t("help")}</div>
</a>
</>
)} */}
<a
href={"https://doc.voce.chat"}
target={"_blank"}
rel="noreferrer"
className={classes.box}
>
<IconAsk className={classes.boxIcon} />
<div className={classes.boxTip}>{t("help")}</div>
</a>
</div>
</div>
)}
</div>
{createChannelVisible && (
<ChannelModal personal={true} closeModal={toggleChannelModalVisible} />
{editorModalVisible && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setEditorModalVisible(false)}
>
<div
className="flex flex-col w-[780px] max-w-[95vw] max-h-[85vh] bg-white dark:bg-gray-900 rounded-lg drop-shadow overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<h2 className="text-lg font-semibold text-gray-700 dark:text-gray-50"></h2>
<CloseIcon
className="cursor-pointer dark:fill-white"
onClick={() => setEditorModalVisible(false)}
/>
</div>
{/* Body */}
<div className="flex flex-1 overflow-hidden">
{/* Left sidebar */}
<div className="w-28 shrink-0 border-r border-gray-200 dark:border-gray-700 p-3 flex flex-col gap-1">
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2 px-1"></p>
<button
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
previewPage === "after_signin"
? "bg-primary-400 text-white"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
onClick={() => setPreviewPage("after_signin")}
>
</button>
<button
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
previewPage === "landing"
? "bg-primary-400 text-white"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
onClick={() => setPreviewPage("landing")}
>
</button>
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-4">
{/* Status label */}
<p className="text-sm text-gray-500 dark:text-gray-400 shrink-0">
<span className="font-semibold text-gray-900 dark:text-white">
{pageLabel}
</span>
</p>
{/* Actions row */}
<div className="flex gap-3 shrink-0">
{/* File actions */}
<div className="flex flex-col gap-2">
<button
onClick={handleDownload}
className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary-400 text-white text-sm hover:bg-primary-500 active:bg-primary-500 transition-colors whitespace-nowrap"
>
<DownloadIcon className="w-4 h-4 shrink-0 fill-white" />
HTML
</button>
<button
onClick={handleUpload}
disabled={uploading}
className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary-400 text-white text-sm hover:bg-primary-500 active:bg-primary-500 transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M6.25 3C4.45507 3 3 4.45507 3 6.25V17.75C3 19.5449 4.45507 21 6.25 21H17.75C19.5449 21 21 19.5449 21 17.75V6.25C21 4.45507 19.5449 3 17.75 3H6.25ZM4.5 6.25C4.5 5.2835 5.2835 4.5 6.25 4.5H17.75C18.7165 4.5 19.5 5.2835 19.5 6.25V17.75C19.5 18.7165 18.7165 19.5 17.75 19.5H6.25C5.2835 19.5 4.5 18.7168 4.5 17.75V6.25Z" />
<g transform="translate(12,12) scale(1,-1) translate(-12,-12)">
<path d="M16.5303 11.7204L16.4462 11.6478C16.1526 11.4299 15.7359 11.4541 15.4697 11.7204L12.7503 14.4397L12.75 7.75003L12.7432 7.64826C12.6935 7.28218 12.3797 7.00003 12 7.00003L11.8982 7.00688C11.5322 7.05654 11.25 7.37033 11.25 7.75003L11.2503 14.4417L8.52961 11.7198L8.4455 11.6472C8.15193 11.4293 7.73527 11.4534 7.46895 11.7196C7.176 12.0124 7.17591 12.4873 7.46875 12.7803L11.4687 16.7818L11.5529 16.8544C11.8465 17.0724 12.2632 17.0482 12.5295 16.7819L16.5303 12.7811L16.6029 12.6969C16.8208 12.4033 16.7966 11.9867 16.5303 11.7204Z" />
</g>
</svg>
{uploading ? "上传中…" : "上传自定义 HTML 页面"}
</button>
<button
onClick={handleReset}
disabled={resetting}
className="flex items-center gap-2 px-3.5 py-2 rounded-lg border border-solid border-gray-300 dark:border-gray-500 bg-transparent text-sm text-gray-700 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{resetting ? "恢复中…" : "恢复默认"}
</button>
</div>
{/* AI tip */}
<div className="flex-1 border border-dashed border-gray-300 dark:border-gray-400 rounded-lg p-3 flex flex-col justify-between gap-2">
<p className="text-xs text-gray-500 dark:text-gray-400">
使 agent tool
</p>
<button
onClick={handleCopyPrompt}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-400 text-white text-xs hover:bg-primary-500 active:bg-primary-500 transition-colors w-fit"
>
{copied
? <CheckIcon className="w-3.5 h-3.5 shrink-0 fill-white" />
: <CopyIcon className="w-3.5 h-3.5 shrink-0 fill-white" />
}
{copied ? "已复制!" : "一键复制提示词"}
</button>
</div>
</div>
{/* HTML preview */}
<div className="flex flex-col gap-1.5 overflow-hidden flex-1 min-h-0">
<p className="text-sm font-semibold text-gray-700 dark:text-gray-50 shrink-0">
HTML
</p>
<div className="flex-1 overflow-auto bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
{htmlPreview === null ? (
<div className="p-4 text-xs text-gray-400"></div>
) : htmlLines.map((line, i) => (
<div key={i} className="flex hover:bg-gray-200 dark:hover:bg-gray-700">
<span className="w-9 shrink-0 text-right pr-2.5 py-0.5 text-gray-400 dark:text-gray-500 select-none border-r border-gray-200 dark:border-gray-700 text-xs font-mono leading-5">
{i + 1}
</span>
<span className="pl-3 py-0.5 text-gray-700 dark:text-gray-300 whitespace-pre text-xs font-mono leading-5">
{line}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
)}
{userListVisible && <UsersModal closeModal={toggleUserListVisible} />}
{inviteModalVisible && <InviteModal closeModal={toggleInviteModalVisible} />}
{createChannelVisible && (
<ChannelModal personal={true} closeModal={() => setCreateChannelVisible(false)} />
)}
{userListVisible && <UsersModal closeModal={() => setUserListVisible(false)} />}
{inviteModalVisible && <InviteModal closeModal={() => setInviteModalVisible(false)} />}
</>
);
};
+95 -27
View File
@@ -1,47 +1,115 @@
// import React from "react";
import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { shallowEqual } from "react-redux";
import { useNavigate } from "react-router-dom";
import { QRCodeCanvas } from "qrcode.react";
import { BASE_ORIGIN } from "@/app/config";
// import { resetAuthData } from "@/app/slices/auth.data";
import { compareVersion } from "@/utils";
import { useAppSelector } from "@/app/store";
import QRCode from "@/components/QRCode";
import Button from "@/components/styled/Button";
// import useLogout from "@/hooks/useLogout";
// type Props = {};
const IFRAME_MIN_VERSION = "0.5.21";
const GuestBlankPlaceholder = () => {
const { t } = useTranslation("auth");
// const dispatch = useDispatch();
// const { clearLocalData } = useLogout();
const navigateTo = useNavigate();
const iframeRef = useRef<HTMLIFrameElement>(null);
const qrCanvasRef = useRef<HTMLDivElement>(null);
const serverName = useAppSelector((store) => store.server.name, shallowEqual);
const handleSignIn = () => {
// dispatch(resetAuthData());
// clearLocalData();
navigateTo("/login");
const serverVersion = useAppSelector((store) => store.server.version, shallowEqual);
const logo = useAppSelector((store) => store.server.logo, shallowEqual);
const useIframe = useMemo(() => compareVersion(serverVersion, IFRAME_MIN_VERSION) >= 0, [serverVersion]);
const getQRDataURL = (): string | null => {
const canvas = qrCanvasRef.current?.querySelector("canvas");
if (!canvas) return null;
try {
return canvas.toDataURL("image/png");
} catch {
return null;
}
};
const qrUrl = BASE_ORIGIN;
return (
<section className="flex flex-col items-center text-center">
<h2 className="text-3xl text-gray-600 dark:text-gray-50 font-bold">
{t("welcome", { name: serverName })}
</h2>
<div className="flex flex-col gap-2">
<span className="text-gray-400 dark:text-gray-200 my-3 text-sm">
{t("guest_login_tip")}
</span>
<div className="w-44 h-44 self-center mb-4">
<QRCode level="Q" size={1200} link={qrUrl} />
const sendServerInfo = () => {
const qrDataURL = getQRDataURL();
iframeRef.current?.contentWindow?.postMessage(
{
type: "server_info",
name: serverName,
qrDataURL,
signInLabel: t("sign_in"),
guestTip: t("guest_login_tip"),
},
"*"
);
};
useEffect(() => {
if (!useIframe) return;
const handler = (e: MessageEvent) => {
if (!e.data || typeof e.data !== "object") return;
if (e.data.type === "iframe_ready" && e.data.page === "guest") {
sendServerInfo();
}
if (e.data.type === "action" && e.data.action === "sign_in") {
navigateTo("/login");
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [useIframe, serverName, logo, navigateTo, t]);
useEffect(() => {
if (!useIframe) return;
const timer = setTimeout(() => sendServerInfo(), 50);
return () => clearTimeout(timer);
}, [useIframe, serverName, logo]);
if (!useIframe) {
return (
<section className="flex flex-col items-center text-center">
<h2 className="text-3xl text-gray-600 dark:text-gray-50 font-bold">
{t("welcome", { name: serverName })}
</h2>
<div className="flex flex-col gap-2">
<span className="text-gray-400 dark:text-gray-200 my-3 text-sm">
{t("guest_login_tip")}
</span>
<div className="w-44 h-44 self-center mb-4">
<QRCode level="Q" size={1200} link={BASE_ORIGIN} />
</div>
{process.env.NODE_ENV === "development" && <span>{BASE_ORIGIN}</span>}
<Button onClick={() => navigateTo("/login")} className="small">
{t("sign_in")}
</Button>
</div>
{process.env.NODE_ENV == "development" && <span>{qrUrl}</span>}
<Button onClick={handleSignIn} className="small">
{t("sign_in")}
</Button>
</section>
);
}
return (
<>
{/* Hidden QR canvas — React renders it so we can extract dataURL */}
<div ref={qrCanvasRef} className="hidden">
<QRCodeCanvas
value={BASE_ORIGIN}
size={512}
level="Q"
bgColor="#fff"
fgColor="#000"
marginSize={2}
/>
</div>
</section>
<iframe
ref={iframeRef}
src={`${BASE_ORIGIN}/api/page/landing`}
className="w-full h-full border-0"
title="Landing Page"
/>
</>
);
};