后台站点设计UIUX优化
This commit is contained in:
104
frontend/components/BrandLockup.tsx
Normal file
104
frontend/components/BrandLockup.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { brandLogoBox, type BrandLogoFit, type BrandLogoSize, type BrandMark } from "@/lib/brand";
|
||||
|
||||
/** 页眉品牌锁:图加文字 / 纯图 / 纯文字。图槽按画幅占位,铺法决定图怎么填进框。 */
|
||||
export default function BrandLockup({
|
||||
mode,
|
||||
size = "sq",
|
||||
fit = "contain",
|
||||
name,
|
||||
wordmark = "",
|
||||
slogan = "",
|
||||
logoLight = "",
|
||||
logoDark = "",
|
||||
frame = false,
|
||||
}: {
|
||||
mode: BrandMark;
|
||||
size?: BrandLogoSize;
|
||||
fit?: BrandLogoFit;
|
||||
name: string;
|
||||
wordmark?: string;
|
||||
slogan?: string;
|
||||
logoLight?: string;
|
||||
logoDark?: string;
|
||||
/** 后台预览画出画幅框,方便看非正方形占位 */
|
||||
frame?: boolean;
|
||||
}) {
|
||||
const label = wordmark.trim() || name;
|
||||
const line = slogan.trim();
|
||||
const showImage = mode !== "text";
|
||||
const showText = mode !== "image";
|
||||
const box = brandLogoBox(size);
|
||||
const src = logoLight || logoDark;
|
||||
const both = !!(logoLight && logoDark && logoLight !== logoDark);
|
||||
const useBox = showImage && (!!src || frame);
|
||||
|
||||
return (
|
||||
<span className="j13-brand-lockup inline-flex items-center gap-2.5 min-w-0">
|
||||
{showImage ? (
|
||||
useBox ? (
|
||||
<span
|
||||
className="j13-brand-slot"
|
||||
data-frame={frame ? "1" : undefined}
|
||||
data-fit={src ? fit : undefined}
|
||||
style={{ width: box.w, height: box.h }}
|
||||
>
|
||||
{src ? (
|
||||
both ? (
|
||||
<>
|
||||
<Logo src={logoLight} fit={fit} extra="j13-brand-logo-light" />
|
||||
<Logo src={logoDark} fit={fit} extra="j13-brand-logo-dark" />
|
||||
</>
|
||||
) : (
|
||||
<Logo src={src} fit={fit} />
|
||||
)
|
||||
) : (
|
||||
<Bubble h={box.h} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Bubble h={36} />
|
||||
)
|
||||
) : null}
|
||||
{showText ? (
|
||||
<span className="leading-none min-w-0">
|
||||
<span className="block font-extrabold text-[17px] tracking-tight truncate" style={{ color: "var(--ink)" }}>
|
||||
{label}
|
||||
</span>
|
||||
{line ? (
|
||||
<span className="block text-[10.5px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }}>
|
||||
{line}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?: string }) {
|
||||
if (fit === "repeat") {
|
||||
return (
|
||||
<span
|
||||
className={`j13-brand-logo j13-brand-logo-tile ${extra}`}
|
||||
style={{ backgroundImage: `url("${src.replace(/["\\\n\r]/g, "")}")` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={src} alt="" className={`j13-brand-logo ${extra}`} />
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ h }: { h: number }) {
|
||||
return (
|
||||
<span
|
||||
className="j13-brand-bubble rounded-2xl flex items-center justify-center shrink-0 transition-transform duration-300 group-hover:rotate-[-6deg]"
|
||||
style={{ width: h, height: h, background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
>
|
||||
<svg width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import pkg from "../package.json";
|
||||
import type { FooterLink } from "@/lib/brand";
|
||||
import FooterTiming from "./FooterTiming";
|
||||
|
||||
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
|
||||
@@ -9,8 +10,14 @@ function realVersion(pkgVersion: string): string {
|
||||
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
|
||||
}
|
||||
|
||||
// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成)
|
||||
export default function Footer({ siteName = "姜十三社区" }: { siteName?: string }) {
|
||||
// 极简页脚:版权 + 合规链接 + 实时性能指标
|
||||
export default function Footer({
|
||||
siteName = "姜十三社区",
|
||||
links = [],
|
||||
}: {
|
||||
siteName?: string;
|
||||
links?: FooterLink[];
|
||||
}) {
|
||||
const year = new Date().getFullYear();
|
||||
const brand = siteName.trim() || "姜十三社区";
|
||||
|
||||
@@ -21,8 +28,22 @@ export default function Footer({ siteName = "姜十三社区" }: { siteName?: st
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-x-4 gap-y-2 flex-wrap">
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||
{links.length > 0 ? (
|
||||
<nav aria-label="页脚链接" className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
||||
{links.map((item) => (
|
||||
<a
|
||||
key={`${item.label}-${item.url}`}
|
||||
href={item.url}
|
||||
className="meta text-[12px] hover:underline"
|
||||
{...(item.new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
<FooterTiming version={realVersion(pkg.version)} />
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -29,7 +29,9 @@ import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import Avatar from "./Avatar";
|
||||
import BrandLockup from "./BrandLockup";
|
||||
import { useAllowMessages } from "./MessagesPolicyProvider";
|
||||
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
||||
|
||||
export default function Header({
|
||||
initialUser,
|
||||
@@ -37,6 +39,13 @@ export default function Header({
|
||||
initialChatUnread = 0,
|
||||
initialTheme = "light",
|
||||
siteName = "姜十三",
|
||||
wordmark = "",
|
||||
slogan = "",
|
||||
logoLight = "",
|
||||
logoDark = "",
|
||||
brandMark = "image_text",
|
||||
brandLogoSize = "sq",
|
||||
brandLogoFit = "contain",
|
||||
allowRegister = true,
|
||||
}: {
|
||||
initialUser: User | null;
|
||||
@@ -44,6 +53,13 @@ export default function Header({
|
||||
initialChatUnread?: number;
|
||||
initialTheme?: "light" | "dark";
|
||||
siteName?: string;
|
||||
wordmark?: string;
|
||||
slogan?: string;
|
||||
logoLight?: string;
|
||||
logoDark?: string;
|
||||
brandMark?: BrandMark;
|
||||
brandLogoSize?: BrandLogoSize;
|
||||
brandLogoFit?: BrandLogoFit;
|
||||
allowRegister?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -205,23 +221,17 @@ export default function Header({
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
|
||||
{/* 左组:品牌 + 主导航 */}
|
||||
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<span
|
||||
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
|
||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="leading-none">
|
||||
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
{siteName}
|
||||
</span>
|
||||
<span className="block text-[10.5px] mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||
J13 社区
|
||||
</span>
|
||||
</span>
|
||||
<Link href="/" className="flex items-center group min-w-0" aria-label={siteName}>
|
||||
<BrandLockup
|
||||
mode={brandMark}
|
||||
size={brandLogoSize}
|
||||
fit={brandLogoFit}
|
||||
name={siteName}
|
||||
wordmark={wordmark}
|
||||
slogan={slogan}
|
||||
logoLight={logoLight}
|
||||
logoDark={logoDark}
|
||||
/>
|
||||
</Link>
|
||||
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
|
||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
|
||||
|
||||
183
frontend/components/SiteBrandProvider.tsx
Normal file
183
frontend/components/SiteBrandProvider.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark } from "@/lib/brand";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
|
||||
|
||||
export type SiteBrand = {
|
||||
site_name: string;
|
||||
site_wordmark: string;
|
||||
site_slogan: string;
|
||||
logo_light_url: string;
|
||||
logo_dark_url: string;
|
||||
favicon_url: string;
|
||||
brand_mark: PublicSettings["brand_mark"];
|
||||
brand_logo_size: PublicSettings["brand_logo_size"];
|
||||
brand_logo_fit: PublicSettings["brand_logo_fit"];
|
||||
footer_links: PublicSettings["footer_links"];
|
||||
};
|
||||
|
||||
const SiteBrandContext = createContext<SiteBrand | null>(null);
|
||||
|
||||
function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
|
||||
return {
|
||||
site_name: s.site_name,
|
||||
site_wordmark: s.site_wordmark,
|
||||
site_slogan: s.site_slogan,
|
||||
logo_light_url: s.logo_light_url,
|
||||
logo_dark_url: s.logo_dark_url,
|
||||
favicon_url: s.favicon_url,
|
||||
brand_mark: s.brand_mark,
|
||||
brand_logo_size: s.brand_logo_size,
|
||||
brand_logo_fit: s.brand_logo_fit,
|
||||
footer_links: s.footer_links,
|
||||
};
|
||||
}
|
||||
|
||||
function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
|
||||
return (
|
||||
a.site_name === b.site_name &&
|
||||
a.site_wordmark === b.site_wordmark &&
|
||||
a.site_slogan === b.site_slogan &&
|
||||
a.logo_light_url === b.logo_light_url &&
|
||||
a.logo_dark_url === b.logo_dark_url &&
|
||||
a.favicon_url === b.favicon_url &&
|
||||
a.brand_mark === b.brand_mark &&
|
||||
a.brand_logo_size === b.brand_logo_size &&
|
||||
a.brand_logo_fit === b.brand_logo_fit &&
|
||||
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
|
||||
);
|
||||
}
|
||||
|
||||
function applyFavicon(url: string) {
|
||||
const existing = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
|
||||
if (!url) {
|
||||
document.getElementById("j13-favicon")?.remove();
|
||||
const hasDefault = [...existing].some((el) => el.id !== "j13-favicon" && el.href.includes("favicon"));
|
||||
if (!hasDefault) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = "/favicon.ico";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
return;
|
||||
}
|
||||
existing.forEach((el) => {
|
||||
if (el.id !== "j13-favicon") el.remove();
|
||||
});
|
||||
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.id = "j13-favicon";
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (link.getAttribute("href") !== url) link.href = url;
|
||||
}
|
||||
|
||||
function retitle(prev: SiteBrand, next: SiteBrand) {
|
||||
if (prev.site_name === next.site_name && prev.site_slogan === next.site_slogan) return;
|
||||
const oldDefault = siteDocumentTitle(prev.site_name, prev.site_slogan);
|
||||
const nextDefault = siteDocumentTitle(next.site_name, next.site_slogan);
|
||||
const cur = document.title;
|
||||
if (cur === oldDefault) {
|
||||
document.title = nextDefault;
|
||||
return;
|
||||
}
|
||||
const oldName = prev.site_name.trim() || "姜十三论坛";
|
||||
const newName = next.site_name.trim() || "姜十三论坛";
|
||||
const suffix = ` · ${oldName}`;
|
||||
if (oldName !== newName && cur.endsWith(suffix)) {
|
||||
document.title = cur.slice(0, -suffix.length) + ` · ${newName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSiteBrand(): SiteBrand {
|
||||
const ctx = useContext(SiteBrandContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
site_name: "姜十三论坛",
|
||||
site_wordmark: "",
|
||||
site_slogan: "",
|
||||
logo_light_url: "",
|
||||
logo_dark_url: "",
|
||||
favicon_url: "",
|
||||
brand_mark: "image_text",
|
||||
brand_logo_size: "sq",
|
||||
brand_logo_fit: "contain",
|
||||
footer_links: [],
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */
|
||||
export default function SiteBrandProvider({
|
||||
initial,
|
||||
children,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [brand, setBrand] = useState<SiteBrand>(() => fromSettings(initial));
|
||||
const brandRef = useRef(brand);
|
||||
brandRef.current = brand;
|
||||
|
||||
const value = useMemo(() => brand, [brand]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = fromSettings(initial);
|
||||
setBrand((prev) => {
|
||||
if (sameBrand(prev, next)) return prev;
|
||||
retitle(prev, next);
|
||||
applyFavicon(next.favicon_url);
|
||||
return next;
|
||||
});
|
||||
}, [initial]);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = (raw: Partial<PublicSettings> | RtSettingsData) => {
|
||||
const prev = brandRef.current;
|
||||
const next: SiteBrand = {
|
||||
site_name: typeof raw.site_name === "string" && raw.site_name.trim() ? raw.site_name : prev.site_name,
|
||||
site_wordmark: typeof raw.site_wordmark === "string" ? raw.site_wordmark : prev.site_wordmark,
|
||||
site_slogan: typeof raw.site_slogan === "string" ? raw.site_slogan : prev.site_slogan,
|
||||
logo_light_url: typeof raw.logo_light_url === "string" ? raw.logo_light_url : prev.logo_light_url,
|
||||
logo_dark_url: typeof raw.logo_dark_url === "string" ? raw.logo_dark_url : prev.logo_dark_url,
|
||||
favicon_url: typeof raw.favicon_url === "string" ? raw.favicon_url : prev.favicon_url,
|
||||
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
|
||||
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
|
||||
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
|
||||
footer_links: Array.isArray(raw.footer_links) ? raw.footer_links : prev.footer_links,
|
||||
};
|
||||
if (sameBrand(prev, next)) return;
|
||||
retitle(prev, next);
|
||||
applyFavicon(next.favicon_url);
|
||||
brandRef.current = next;
|
||||
setBrand(next);
|
||||
};
|
||||
|
||||
const off = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||
if (data) apply(data);
|
||||
});
|
||||
|
||||
let last = 0;
|
||||
const offVisible = onDocumentVisible(() => {
|
||||
const now = Date.now();
|
||||
if (now - last < VISIBLE_RECONCILE_GAP_MS) return;
|
||||
last = now;
|
||||
void apiGetSettings()
|
||||
.then(apply)
|
||||
.catch(() => {});
|
||||
}, VISIBLE_RECONCILE_GAP_MS);
|
||||
|
||||
return () => {
|
||||
off();
|
||||
offVisible();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <SiteBrandContext.Provider value={value}>{children}</SiteBrandContext.Provider>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import BackToTop from "@/components/BackToTop";
|
||||
import type { User } from "@/lib/api";
|
||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||
|
||||
function pathIsAdmin(pathname: string | null) {
|
||||
if (!pathname) return false;
|
||||
@@ -39,7 +40,6 @@ export default function SiteChrome({
|
||||
unread,
|
||||
chatUnread,
|
||||
theme,
|
||||
siteName,
|
||||
allowRegister,
|
||||
children,
|
||||
}: {
|
||||
@@ -48,7 +48,6 @@ export default function SiteChrome({
|
||||
unread: number;
|
||||
chatUnread: number;
|
||||
theme: "light" | "dark";
|
||||
siteName: string;
|
||||
allowRegister: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -80,6 +79,8 @@ export default function SiteChrome({
|
||||
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
|
||||
}, [isAdmin]);
|
||||
|
||||
const brand = useSiteBrand();
|
||||
|
||||
if (isAdmin) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -94,7 +95,14 @@ export default function SiteChrome({
|
||||
initialUnread={unread}
|
||||
initialChatUnread={chatUnread}
|
||||
initialTheme={theme}
|
||||
siteName={siteName}
|
||||
siteName={brand.site_name}
|
||||
wordmark={brand.site_wordmark}
|
||||
slogan={brand.site_slogan}
|
||||
logoLight={brand.logo_light_url}
|
||||
logoDark={brand.logo_dark_url}
|
||||
brandMark={brand.brand_mark}
|
||||
brandLogoSize={brand.brand_logo_size}
|
||||
brandLogoFit={brand.brand_logo_fit}
|
||||
allowRegister={allowRegister}
|
||||
/>
|
||||
<main
|
||||
@@ -104,7 +112,7 @@ export default function SiteChrome({
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer siteName={siteName} />
|
||||
<Footer siteName={brand.site_name} links={brand.footer_links} />
|
||||
{showBackToTop ? (
|
||||
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user