mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-11 11:32:26 +08:00
refactor: align project directory architecture
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
.shell {
|
||||
--app-edge: 8px;
|
||||
--app-menu-width: 204px;
|
||||
--window-safe-top: var(--window-header-height);
|
||||
--window-menu-top: var(--window-safe-top);
|
||||
--window-corner-safe-bottom: 0px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.windows {
|
||||
--window-corner-safe-bottom: 6px;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { desktopPlatform } from "../shared/native/platform";
|
||||
import styles from "./AppFrame.module.scss";
|
||||
import { AppHeader } from "./AppHeader";
|
||||
|
||||
const currentPlatform = desktopPlatform();
|
||||
document.documentElement.dataset.platform = currentPlatform;
|
||||
|
||||
export function AppFrame() {
|
||||
const platform = currentPlatform;
|
||||
const platformClasses = platform === "windows" ? [styles.windows] : [];
|
||||
const nativeDesktop = "__TAURI_INTERNALS__" in window;
|
||||
|
||||
return (
|
||||
<div className={[styles.shell, ...platformClasses].filter(Boolean).join(" ")}>
|
||||
<AppHeader platform={platform} nativeDesktop={nativeDesktop} />
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
z-index: 12000;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: var(--window-header-height);
|
||||
background: transparent;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dragLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.uiLayer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
padding-right: 0px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.identity {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import appIcon from "../../src-tauri/icons/32x32.png";
|
||||
import { currentAppVersion } from "../shared/native/appLifecycle";
|
||||
import type { DesktopPlatform } from "../shared/native/platform";
|
||||
import { WindowControls } from "./WindowControls";
|
||||
import { MacTrafficLights } from "./MacTrafficLights";
|
||||
import styles from "./AppHeader.module.scss";
|
||||
|
||||
type AppHeaderProps = {
|
||||
platform: DesktopPlatform;
|
||||
nativeDesktop: boolean;
|
||||
};
|
||||
|
||||
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
|
||||
const showMacTrafficLights = !nativeDesktop && platform === "macos";
|
||||
const showNativeUi = nativeDesktop && platform !== "macos";
|
||||
const [version, setVersion] = useState("…");
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
void currentAppVersion().then((next) => {
|
||||
if (!disposed) setVersion(next);
|
||||
});
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
|
||||
return <header className={styles.root}>
|
||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||
<div className={styles.uiLayer}>
|
||||
{showMacTrafficLights && <MacTrafficLights />}
|
||||
{showNativeUi && <>
|
||||
<div className={styles.identity} aria-label="Cursor BYOK">
|
||||
<img src={appIcon} alt="" />
|
||||
<span>{t("Cursor 助手 v{version}", { version })}</span>
|
||||
</div>
|
||||
<WindowControls />
|
||||
</>}
|
||||
</div>
|
||||
</header>;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--app-region-gap: 16px;
|
||||
--app-toolbar-height: 36px;
|
||||
--app-page-max-width: 1280px;
|
||||
--app-page-padding: 18px;
|
||||
--app-content-left: calc(var(--app-edge) + var(--app-menu-width) );
|
||||
--app-content-top: calc(var(--window-safe-top) + var(--app-toolbar-height) + 10px );
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.menuCard {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
top: calc(var(--window-menu-top));
|
||||
bottom: var(--app-edge);
|
||||
left: var(--app-edge);
|
||||
width: var(--app-menu-width);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.navigationList {
|
||||
--scroll-shadow-bottom-size: 28px;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.navigationGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: color-mix(in srgb, var(--vscode-descriptionForeground) 65%, transparent);
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: normal;
|
||||
padding: 5px 9px 2px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.navigationRow {
|
||||
flex: 0 0 36px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
a,
|
||||
button {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 11px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&[aria-current="page"] {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.menuIndicatorDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
margin-left: auto;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dismissReason {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
font-size: type.$font-size-base;
|
||||
|
||||
textarea {
|
||||
min-height: 78px;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
width: calc(100% - var(--app-content-left));
|
||||
height: 100%;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin-left: var(--app-content-left);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.keepAliveContainer,
|
||||
.keepAlivePage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.actionRegion {
|
||||
position: absolute;
|
||||
z-index: 12500;
|
||||
top: var(--window-safe-top);
|
||||
right: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
|
||||
height: calc(var(--app-content-top) - var(--window-safe-top));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.actions {
|
||||
height: var(--app-toolbar-height);
|
||||
min-width: var(--app-toolbar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 6px;
|
||||
gap: 3px;
|
||||
box-shadow: 0 0px 6px 4px var(--vscode-editor-background);
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
.pageActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { IconifyIcon } from "@iconify/react/offline";
|
||||
import KeepAliveRouteOutlet from "keepalive-for-react-router";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import cursorIconUrl from "../shared/assets/icons/cursor.svg";
|
||||
import { api } from "../shared/api";
|
||||
import { AdMenu } from "./ads/AdMenu";
|
||||
import { FloatingAd } from "./ads/FloatingAd";
|
||||
import { AdActionType, type AdAction, type AdSlot } from "./ads/types";
|
||||
import { PageLayout } from "./layout/PageLayout";
|
||||
import { Card } from "../shared/ui/Card";
|
||||
import { ConfirmDialog } from "../shared/ui/ConfirmDialog";
|
||||
import controls from "../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../shared/ui/Icon";
|
||||
import { TooltipTrigger } from "../shared/ui/TooltipTrigger";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
|
||||
import { useMessage } from "../shared/ui/message";
|
||||
import { VirtualList } from "../shared/virtual/VirtualList";
|
||||
import { useI18n } from "../i18n/store";
|
||||
import { appStore, useAppStore } from "../shared/store/appStore";
|
||||
import { useUpdateStore } from "../shared/store/updateStore";
|
||||
import styles from "./AppLayout.module.scss";
|
||||
import { PageActionsTarget } from "./PageActions";
|
||||
|
||||
type MenuItem =
|
||||
| { kind: "page"; path: string; label: string; icon: IconifyIcon | string }
|
||||
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
|
||||
| { kind: "group"; label: string };
|
||||
|
||||
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"];
|
||||
const readAdStorageKey = "cursor-byok:read-ad-ids";
|
||||
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
|
||||
const tutorialReadStorageKey = "cursor-byok:tutorial-read";
|
||||
const tutorialUrl = "https://docs.leokun.cn";
|
||||
|
||||
function loadStoredAdIds(key: string): Set<string> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
|
||||
return new Set(Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const { busy } = useAppStore();
|
||||
const { availableVersion } = useUpdateStore();
|
||||
const { locale } = useI18n();
|
||||
const message = useMessage();
|
||||
const location = useLocation();
|
||||
const [leftActionTarget, setLeftActionTarget] = useState<HTMLDivElement | null>(null);
|
||||
const [rightActionTarget, setRightActionTarget] = useState<HTMLDivElement | null>(null);
|
||||
const [ads, setAds] = useState<AdSlot[]>([]);
|
||||
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
|
||||
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
|
||||
const [dismissReason, setDismissReason] = useState("");
|
||||
const [confirmTutorial, setConfirmTutorial] = useState(false);
|
||||
const [tutorialRead, setTutorialRead] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(tutorialReadStorageKey) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [readAdIds, setReadAdIds] = useState(() => loadStoredAdIds(readAdStorageKey));
|
||||
const [dismissedAdIds, setDismissedAdIds] = useState(() => loadStoredAdIds(dismissedAdStorageKey));
|
||||
const dismissedAdIdsRef = useRef(dismissedAdIds);
|
||||
dismissedAdIdsRef.current = dismissedAdIds;
|
||||
const adTriggers = useRef(new Map<string, HTMLButtonElement>());
|
||||
const closeAd = useCallback(() => setActiveAd(null), []);
|
||||
const visibleAds = ads.filter((ad) => ad.enabled && ad.placement === "menu" && !dismissedAdIds.has(ad.id));
|
||||
const menuItems: MenuItem[] = [
|
||||
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
|
||||
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
|
||||
{ kind: "group", label: "Harness" },
|
||||
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
|
||||
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
|
||||
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
|
||||
];
|
||||
|
||||
const openTutorial = useCallback(() => {
|
||||
setConfirmTutorial(false);
|
||||
void api.openExternalUrl(tutorialUrl)
|
||||
.then(() => {
|
||||
setTutorialRead(true);
|
||||
try {
|
||||
localStorage.setItem(tutorialReadStorageKey, "true");
|
||||
} catch {
|
||||
// Read state remains valid for the current session when storage is unavailable.
|
||||
}
|
||||
})
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let pending = false;
|
||||
let lastRequestedAt = 0;
|
||||
const refreshAds = () => {
|
||||
const now = Date.now();
|
||||
if (pending || now - lastRequestedAt < 10_000) return;
|
||||
pending = true;
|
||||
lastRequestedAt = now;
|
||||
void api.ads(dismissedAdIdsRef.current, locale)
|
||||
.then((runtime) => {
|
||||
if (disposed) return;
|
||||
setAds(runtime.slots);
|
||||
setActiveAd((current) => current
|
||||
? runtime.slots.find((ad) => ad.id === current.id) ?? null
|
||||
: null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
setAds([]);
|
||||
setActiveAd(null);
|
||||
})
|
||||
.finally(() => { pending = false; });
|
||||
};
|
||||
const refreshVisibleAds = () => {
|
||||
if (document.visibilityState === "visible") refreshAds();
|
||||
};
|
||||
|
||||
refreshAds();
|
||||
window.addEventListener("focus", refreshAds);
|
||||
document.addEventListener("visibilitychange", refreshVisibleAds);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.removeEventListener("focus", refreshAds);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleAds);
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
const openAd = useCallback((ad: AdSlot) => {
|
||||
setReadAdIds((current) => {
|
||||
if (current.has(ad.id)) return current;
|
||||
const next = new Set(current).add(ad.id);
|
||||
try {
|
||||
localStorage.setItem(readAdStorageKey, JSON.stringify([...next]));
|
||||
} catch {
|
||||
// Read state remains valid for the current session when storage is unavailable.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setActiveAd((current) => current?.id === ad.id ? null : ad);
|
||||
}, []);
|
||||
|
||||
const performAdAction = useCallback((action: AdAction) => {
|
||||
if (action.type !== AdActionType.OpenBrowser) return;
|
||||
void api.openExternalUrl(action.url)
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
|
||||
const openDismissAd = useCallback((ad: AdSlot) => {
|
||||
setDismissReason("");
|
||||
setDismissCandidate(ad);
|
||||
}, []);
|
||||
|
||||
const closeDismissAd = useCallback(() => {
|
||||
setDismissCandidate(null);
|
||||
setDismissReason("");
|
||||
}, []);
|
||||
|
||||
const dismissAd = useCallback(() => {
|
||||
if (!dismissCandidate) return;
|
||||
const dismissedId = dismissCandidate.id;
|
||||
const reason = dismissReason.trim();
|
||||
setDismissedAdIds((current) => {
|
||||
const next = new Set(current).add(dismissedId);
|
||||
try {
|
||||
localStorage.setItem(dismissedAdStorageKey, JSON.stringify([...next]));
|
||||
} catch {
|
||||
// Dismissed state remains valid for the current session when storage is unavailable.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setActiveAd((current) => current?.id === dismissedId ? null : current);
|
||||
setDismissCandidate(null);
|
||||
setDismissReason("");
|
||||
void api.dismissAd(dismissedId, reason).catch(() => {
|
||||
// Dismissal is intentionally fire-and-forget so reporting never blocks the UI.
|
||||
});
|
||||
}, [dismissCandidate, dismissReason]);
|
||||
|
||||
return <PageLayout className={styles.root}>
|
||||
<Card as="aside" className={styles.menuCard}>
|
||||
<nav className={styles.navigation} aria-label={t("主菜单")}>
|
||||
<VirtualList
|
||||
items={menuItems}
|
||||
itemKey={(item) => item.kind === "group" ? `group-${item.label}` : item.kind === "external" ? `external-${item.id}` : item.path}
|
||||
estimatedItemHeight={36}
|
||||
itemGap={3}
|
||||
className={`${styles.navigationList} scroll-shadow-bottom`}
|
||||
>
|
||||
{(item) => item.kind === "group"
|
||||
? <div className={styles.navigationGroup} key={`group-${item.label}`}>{item.label}</div>
|
||||
: item.kind === "external"
|
||||
? <div className={styles.navigationRow} key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`}
|
||||
onClick={() => setConfirmTutorial(true)}
|
||||
>
|
||||
{typeof item.icon === "string"
|
||||
? <Icon src={item.icon} size="1.3em" />
|
||||
: <Icon icon={item.icon} size="1.3em" />}
|
||||
<span>{item.label}</span>
|
||||
{!tutorialRead && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
: <div className={styles.navigationRow} key={item.path}>
|
||||
<NavLink to={item.path} end={item.path === "/"}>
|
||||
{typeof item.icon === "string"
|
||||
? <Icon src={item.icon} size="1.3em" />
|
||||
: <Icon icon={item.icon} size="1.3em" />}
|
||||
<span>{item.label}</span>
|
||||
{item.path === "/settings" && availableVersion && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
|
||||
</NavLink>
|
||||
</div>}
|
||||
</VirtualList>
|
||||
<AdMenu ads={visibleAds} activeAdId={activeAd?.id} dismissingAdId={dismissCandidate?.id} readAdIds={readAdIds} triggerRefs={adTriggers} onOpen={openAd} onDismiss={openDismissAd} />
|
||||
</nav>
|
||||
</Card>
|
||||
{activeAd && <FloatingAd ad={activeAd} trigger={adTriggers.current.get(activeAd.id) ?? null} onClose={closeAd} onAction={performAdAction} />}
|
||||
<ConfirmDialog
|
||||
id="open-tutorial-dialog"
|
||||
open={confirmTutorial}
|
||||
title={t("打开使用教程?")}
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("打开教程")}
|
||||
onCancel={() => setConfirmTutorial(false)}
|
||||
onConfirm={openTutorial}
|
||||
>
|
||||
<p>{t("将在系统浏览器中打开使用教程,是否继续?")}</p>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
id="dismiss-ad-dialog"
|
||||
open={dismissCandidate !== null}
|
||||
title={t("不再显示此广告")}
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("确认")}
|
||||
onCancel={closeDismissAd}
|
||||
onConfirm={() => void dismissAd()}
|
||||
>
|
||||
<p>{t("你确认不想再看到此广告吗?")}</p>
|
||||
<label className={styles.dismissReason}>
|
||||
<span>{t("原因(可选)")}</span>
|
||||
<textarea
|
||||
value={dismissReason}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder={t("可以告诉我们原因")}
|
||||
onChange={(event) => setDismissReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
<main className={styles.content}>
|
||||
<div className={styles.actionRegion}>
|
||||
<Card className={styles.actions}>
|
||||
<div ref={setLeftActionTarget} className={styles.pageActions} />
|
||||
{location.pathname !== "/" && <TooltipTrigger label={t("刷新")}><button className={controls.iconButton} aria-label={t("刷新")} disabled={busy} onClick={() => void appStore.refresh()}>
|
||||
<Icon className={busy ? controls.spin : ""} icon={refreshIcon} size="1.1em" />
|
||||
</button></TooltipTrigger>}
|
||||
<div ref={setRightActionTarget} className={styles.pageActions} />
|
||||
</Card>
|
||||
</div>
|
||||
<PageActionsTarget.Provider value={{ left: leftActionTarget, right: rightActionTarget }}>
|
||||
<KeepAliveRouteOutlet
|
||||
activeCacheKey={location.pathname}
|
||||
include={keptAlivePages}
|
||||
max={keptAlivePages.length}
|
||||
enableActivity
|
||||
containerClassName={styles.keepAliveContainer}
|
||||
cacheNodeClassName={styles.keepAlivePage}
|
||||
/>
|
||||
</PageActionsTarget.Provider>
|
||||
</main>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
border: 0.5px solid rgb(0 0 0 / 18%);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.close {
|
||||
background: #ff5f57;
|
||||
}
|
||||
|
||||
.minimize {
|
||||
background: #febc2e;
|
||||
}
|
||||
|
||||
.zoom {
|
||||
background: #28c840;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import styles from "./MacTrafficLights.module.scss";
|
||||
|
||||
export function MacTrafficLights() {
|
||||
return <div className={styles.root} aria-hidden="true">
|
||||
<span className={[styles.light, styles.close].join(" ")} />
|
||||
<span className={[styles.light, styles.minimize].join(" ")} />
|
||||
<span className={[styles.light, styles.zoom].join(" ")} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useKeepAliveContext } from "keepalive-for-react";
|
||||
|
||||
type PageActionsTargets = {
|
||||
left: HTMLElement | null;
|
||||
right: HTMLElement | null;
|
||||
};
|
||||
|
||||
export const PageActionsTarget = createContext<PageActionsTargets>({ left: null, right: null });
|
||||
|
||||
export function PageActions({ children, position = "right" }: { children: ReactNode; position?: "left" | "right" }) {
|
||||
const targets = useContext(PageActionsTarget);
|
||||
const { active } = useKeepAliveContext();
|
||||
const target = targets[position];
|
||||
return active && target ? createPortal(children, target) : null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.root {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
pointer-events: auto;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "../shared/ui/Icon";
|
||||
import {
|
||||
windowCloseIcon,
|
||||
windowMaximizeIcon,
|
||||
windowMinimizeIcon,
|
||||
windowRestoreIcon,
|
||||
} from "../shared/ui/icons";
|
||||
import styles from "./WindowControls.module.scss";
|
||||
|
||||
export function WindowControls() {
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const appWindow = getCurrentWindow();
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const updateMaximized = async () => {
|
||||
const next = await appWindow.isMaximized();
|
||||
if (!disposed) setMaximized(next);
|
||||
};
|
||||
|
||||
void updateMaximized();
|
||||
void appWindow.onResized(() => void updateMaximized()).then((stop) => {
|
||||
if (disposed) stop();
|
||||
else unlisten = stop;
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const maximizeLabel = maximized ? t("还原窗口") : t("最大化窗口");
|
||||
|
||||
return <div className={styles.root}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
aria-label={t("最小化窗口")}
|
||||
title={t("最小化窗口")}
|
||||
onClick={() => void appWindow.minimize()}
|
||||
>
|
||||
<Icon icon={windowMinimizeIcon} size="1.1em" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
aria-label={maximizeLabel}
|
||||
title={maximizeLabel}
|
||||
onClick={() => void appWindow.toggleMaximize()}
|
||||
>
|
||||
<Icon icon={maximized ? windowRestoreIcon : windowMaximizeIcon} size="1.1em" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.button, styles.close].join(" ")}
|
||||
aria-label={t("关闭窗口")}
|
||||
title={t("关闭窗口")}
|
||||
onClick={() => void appWindow.close()}
|
||||
>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { RefObject } from "react";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { windowCloseIcon } from "../../shared/ui/icons";
|
||||
import type { AdSlot } from "./types";
|
||||
import styles from "./Ads.module.scss";
|
||||
|
||||
type AdMenuProps = {
|
||||
ads: AdSlot[];
|
||||
activeAdId?: string;
|
||||
dismissingAdId?: string;
|
||||
readAdIds: ReadonlySet<string>;
|
||||
triggerRefs: RefObject<Map<string, HTMLButtonElement>>;
|
||||
onOpen: (ad: AdSlot) => void;
|
||||
onDismiss: (ad: AdSlot) => void;
|
||||
};
|
||||
|
||||
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
|
||||
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
|
||||
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
|
||||
<button
|
||||
ref={(node) => {
|
||||
if (node) triggerRefs.current.set(ad.id, node);
|
||||
else triggerRefs.current.delete(ad.id);
|
||||
}}
|
||||
type="button"
|
||||
className={styles.menuSlot}
|
||||
title={`${ad.target.title}\n${ad.target.description}`}
|
||||
aria-label={`${ad.target.title},${ad.target.description}${readAdIds.has(ad.id) ? "" : `,${t("未读")}`}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={activeAdId === ad.id}
|
||||
aria-controls={activeAdId === ad.id ? "menu-ad-dialog" : undefined}
|
||||
onClick={() => onOpen(ad)}
|
||||
>
|
||||
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
|
||||
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
|
||||
<span className={styles.menuCopy}>
|
||||
<span className={styles.menuTitle}>{ad.target.title}</span>
|
||||
<span className={styles.menuSubtitle}>{ad.target.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuDismiss}
|
||||
aria-label={`${t("不再显示广告")}:${ad.target.title}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={dismissingAdId === ad.id}
|
||||
aria-controls={dismissingAdId === ad.id ? "dismiss-ad-dialog" : undefined}
|
||||
onClick={() => onDismiss(ad)}
|
||||
>
|
||||
<Icon icon={windowCloseIcon} size="1em" />
|
||||
</button>
|
||||
</div>)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.menuSlots {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.menuSlotContainer {
|
||||
position: relative;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.menuSlot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 32px 0 8px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.menuDismiss {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: color-mix(in srgb, var(--vscode-foreground) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.unreadDot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 25px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--vscode-errorForeground);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
.menuImage {
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menuCopy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.menuTitle,
|
||||
.menuSubtitle {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menuTitle {
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menuSubtitle {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.floatingAd {
|
||||
position: fixed;
|
||||
z-index: 20000;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: min(360px, calc(100vh - 32px));
|
||||
max-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
|
||||
animation: ad-enter 180ms ease-out;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex: 0 0 112px;
|
||||
position: relative;
|
||||
height: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.promotionLabel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 3px 7px;
|
||||
color: white;
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 48%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: type.$font-size-xl;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 16%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background: rgb(0 0 0 / 30%);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
--scroll-shadow-bottom-size: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bodyContent {
|
||||
padding: 14px 16px 18px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
color: #202020;
|
||||
background: #f2f2f2;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ad-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.floatingAd {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { windowCloseIcon } from "../../shared/ui/icons";
|
||||
import { VirtualList } from "../../shared/virtual/VirtualList";
|
||||
import type { AdAction, AdSlot } from "./types";
|
||||
import styles from "./Ads.module.scss";
|
||||
|
||||
type AdTextSection =
|
||||
| { key: "intro"; kind: "intro" }
|
||||
| { key: string; kind: "detail"; label: string; value: string };
|
||||
|
||||
type FloatingAdProps = {
|
||||
ad: AdSlot;
|
||||
trigger: HTMLButtonElement | null;
|
||||
onClose: () => void;
|
||||
onAction: (action: AdAction) => void;
|
||||
};
|
||||
|
||||
export function FloatingAd({ ad, trigger, onClose, onAction }: FloatingAdProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const textSections = useMemo<AdTextSection[]>(() => [
|
||||
{ key: "intro", kind: "intro" },
|
||||
...ad.content.details.map((detail, index) => ({
|
||||
key: `detail-${index}-${detail.label}`,
|
||||
kind: "detail" as const,
|
||||
label: detail.label,
|
||||
value: detail.value,
|
||||
})),
|
||||
], [ad.content.details]);
|
||||
|
||||
useEffect(() => {
|
||||
dialog.current?.focus();
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!dialog.current?.contains(target) && !trigger?.contains(target)) onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
trigger?.focus();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, trigger]);
|
||||
|
||||
return createPortal(<div
|
||||
id="menu-ad-dialog"
|
||||
ref={dialog}
|
||||
className={styles.floatingAd}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={ad.content.title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={styles.hero}>
|
||||
<img src={ad.content.imageUrl} alt="" />
|
||||
<span className={styles.promotionLabel}>{t("推广")}</span>
|
||||
<button type="button" className={styles.close} aria-label={t("关闭广告")} onClick={() => { onClose(); trigger?.focus(); }}>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>
|
||||
<VirtualList
|
||||
items={textSections}
|
||||
itemKey="key"
|
||||
estimatedItemHeight={58}
|
||||
itemGap={12}
|
||||
className={`${styles.body} scroll-shadow-bottom`}
|
||||
contentClassName={styles.bodyContent}
|
||||
>
|
||||
{(section) => section.kind === "intro"
|
||||
? <div className={styles.intro}>
|
||||
<h2>{ad.content.title}</h2>
|
||||
<p>{ad.content.description}</p>
|
||||
</div>
|
||||
: <div className={styles.detail}>
|
||||
<h3>{section.label}</h3>
|
||||
<p>{section.value}</p>
|
||||
</div>}
|
||||
</VirtualList>
|
||||
<div className={styles.footer}>
|
||||
<button type="button" onClick={() => { onClose(); onAction(ad.content.button.action); }}>{ad.content.button.label}</button>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const AdActionType = {
|
||||
OpenBrowser: "open_browser",
|
||||
} as const;
|
||||
|
||||
export type AdAction = {
|
||||
type: typeof AdActionType.OpenBrowser;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type AdSlot = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
placement: "menu";
|
||||
target: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
content: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
details: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
button: {
|
||||
label: string;
|
||||
action: AdAction;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type AdRuntime = {
|
||||
slots: AdSlot[];
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
top: var(--app-content-top);
|
||||
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
|
||||
margin: 0;
|
||||
padding-left:0;
|
||||
font-size: type.$font-size-xxl;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.fixedContent {
|
||||
width: calc(100% - var(--app-page-padding) * 2);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding-top: var(--app-content-top);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.fixedContent,
|
||||
.fixedSection {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fixedSection {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { VirtualPageSection } from "./VirtualPage";
|
||||
import { VirtualPage } from "./VirtualPage";
|
||||
import styles from "./PageContent.module.scss";
|
||||
|
||||
export function PageContent({ title, sections, contentClassName, fixed = false }: { title?: ReactNode; sections: VirtualPageSection[]; contentClassName?: string; fixed?: boolean }) {
|
||||
return <div className={styles.root}>
|
||||
{fixed && title != null && <div className={styles.title}>{title}</div>}
|
||||
{fixed
|
||||
? <div className={[styles.fixedContent, contentClassName].filter(Boolean).join(" ")}>{sections.map((section) => <section className={styles.fixedSection} key={section.key}>{section.content}</section>)}</div>
|
||||
: <VirtualPage title={title} sections={sections} contentClassName={contentClassName} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header,
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import styles from "./PageLayout.module.scss";
|
||||
|
||||
type PageLayoutProps = {
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function PageLayout({ as: Component = "div", className, header, footer, children }: PageLayoutProps) {
|
||||
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>
|
||||
{header && <div className={styles.header}>{header}</div>}
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</Component>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: calc(100% - var(--app-page-padding) * 2);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding-top: var(--app-content-top);
|
||||
padding-bottom: var(--app-page-padding);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
font-size: type.$font-size-xxl;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { VirtualList } from "../../shared/virtual/VirtualListEngine";
|
||||
import styles from "./VirtualPage.module.scss";
|
||||
|
||||
export type VirtualPageSection = {
|
||||
key: string;
|
||||
estimatedHeight: number;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function VirtualPage({ title, sections, className, contentClassName }: { title?: ReactNode; sections: VirtualPageSection[]; className?: string; contentClassName?: string }) {
|
||||
const getKey = useCallback((section: VirtualPageSection) => section.key, []);
|
||||
const estimateSize = useCallback((section: VirtualPageSection) => section.estimatedHeight, []);
|
||||
const renderItem = useCallback((section: VirtualPageSection, { index }: { index: number }) => <section className={styles.section}>
|
||||
{index === 0 && title != null && <div className={styles.title}>{title}</div>}
|
||||
{section.content}
|
||||
</section>, [title]);
|
||||
|
||||
return <VirtualList
|
||||
items={sections}
|
||||
getKey={getKey}
|
||||
estimateSize={estimateSize}
|
||||
renderItem={renderItem}
|
||||
overscan={2}
|
||||
itemGap={16}
|
||||
scrollbarSize={7}
|
||||
scrollbarInsetTop="var(--app-content-top)"
|
||||
className={[styles.root, "scroll-shadow-top", className].filter(Boolean).join(" ")}
|
||||
contentClassName={[styles.content, contentClassName].filter(Boolean).join(" ")}
|
||||
/>;
|
||||
}
|
||||
Reference in New Issue
Block a user