From 0f1bbf852cdc9b42a65dfe6b5378958148a3c845 Mon Sep 17 00:00:00 2001 From: haorwen Date: Fri, 26 Jun 2026 09:36:01 +0800 Subject: [PATCH] feat: add page API RTK Query endpoints and update GuestBlankPlaceholder; bump version to 0.9.91 --- package.json | 2 +- src/app/services/base.query.ts | 1 + src/app/services/server.ts | 33 ++ src/components/BlankPlaceholder.tsx | 630 +++++++++++++++------- src/routes/chat/GuestBlankPlaceholder.tsx | 122 ++++- 5 files changed, 563 insertions(+), 225 deletions(-) diff --git a/package.json b/package.json index 914d8f9e..7b46406f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/services/base.query.ts b/src/app/services/base.query.ts index 65c2023b..6665c796 100644 --- a/src/app/services/base.query.ts +++ b/src/app/services/base.query.ts @@ -57,6 +57,7 @@ const whiteList404 = [ "deleteMessage", "deleteMessages", "getWidgetExtCSS", + "getPageHtml", ]; const baseQuery = fetchBaseQuery({ baseUrl: BASE_URL, diff --git a/src/app/services/server.ts b/src/app/services/server.ts index f264da9e..604c88dd 100644 --- a/src/app/services/server.ts +++ b/src/app/services/server.ts @@ -497,6 +497,33 @@ export const serverApi = createApi({ getAutoTunnelInfo: builder.query({ 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({ + query: (page) => ({ + url: `/page/${page}`, + responseHandler: "text", + }), + }), + uploadPageHtml: builder.mutation({ + query: ({ page, html }) => ({ + url: `/page/${page}`, + method: "PUT", + headers: { "content-type": "text/plain" }, + body: html, + }), + }), + resetPageHtml: builder.mutation({ + query: (page) => ({ + url: `/page/${page}`, + method: "DELETE", + }), + }), }), }); @@ -562,4 +589,10 @@ export const { useStopCloudflaredMutation, useGetAutoTunnelInfoQuery, useLazyGetAutoTunnelInfoQuery, + useGetPageApiKeyQuery, + useGeneratePageApiKeyMutation, + useGetPageHtmlQuery, + useLazyGetPageHtmlQuery, + useUploadPageHtmlMutation, + useResetPageHtmlMutation, } = serverApi; diff --git a/src/components/BlankPlaceholder.tsx b/src/components/BlankPlaceholder.tsx index 1ad1b2b9..1f4e50ab 100644 --- a/src/components/BlankPlaceholder.tsx +++ b/src/components/BlankPlaceholder.tsx @@ -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 = ({ type = "chat" }) => { - const navigate = useNavigate(); + const { t } = useTranslation("welcome"); + const iframeRef = useRef(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("after_signin"); + const [cacheBust, setCacheBust] = useState(0); + const [htmlPreview, setHtmlPreview] = useState(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:///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 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 ( + <> +
+
+

+ {t("title", { name: server.name })} +

+

+ ( + + {content} + + ), + }, + }} + > + {server.description ? server.description : t("desc")} + + {isAdmin && ( + + + + )} +

+
+
+ {(isAdmin || !onlyAdminCanInvite) && ( + + )} + {licenseInfo?.user_limit == 20 && showVoceSpace ? ( + + ) : ( + + )} + {showVoceSpace ? ( + + + + + + + + + + +
{t("vocespace")}
+
+ ) : ( + + +
{t("download")}
+
+ )} + + +
{t("help")}
+
+
+
+ {createChannelVisible && setCreateChannelVisible(false)} />} + {userListVisible && setUserListVisible(false)} />} + {inviteModalVisible && setInviteModalVisible(false)} />} + + ); + } return ( <> -
-
-

- {t("title", { name: server.name })} -

-

- { - return ( - <> - - {content} - - - ); - }, - }, - }} +

+