diff --git a/src/components/layout/Navbar.astro b/src/components/layout/Navbar.astro index 7b5454a5fd..55662430d4 100644 --- a/src/components/layout/Navbar.astro +++ b/src/components/layout/Navbar.astro @@ -21,6 +21,7 @@ import { getFallbackFormat, getImageFormats } from "@/utils/image-utils"; import { isHomePage } from "@/utils/layout-utils"; import { url } from "@/utils/url-utils"; import DropdownMenu from "./DropdownMenu.astro"; +import NavbarSocialInteraction from "./NavbarSocialInteraction.astro"; import NavMenuPanel from "./NavMenuPanel.astro"; const className = Astro.props.class; @@ -193,9 +194,11 @@ if (isLocalSrcLogo) { )} + + diff --git a/src/components/widget/Profile.astro b/src/components/widget/Profile.astro index fa002b43c9..013aff0e9c 100644 --- a/src/components/widget/Profile.astro +++ b/src/components/widget/Profile.astro @@ -12,7 +12,7 @@ const { style } = Astro.props; const className = Astro.props.class; --- -
+
- {profileConfig.links.length > 1 && profileConfig.links.map(item => - { - const showName = item.showName; - const className = showName - ? "btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95" - : "btn-regular rounded-lg h-10 w-10 active:scale-90"; + {profileConfig.links.map(item => { + const showName = item.showName; + const className = showName + ? "btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95 flex items-center" + : "btn-regular rounded-lg h-10 w-10 active:scale-90 flex items-center justify-center"; + const targetUrl = item.interaction?.copyText ? item.url : (item.url.startsWith("mailto:") ? "#" : item.url); - if (item.url.startsWith("mailto:")) { - const encodedEmail = Buffer.from(item.url.replace("mailto:", "")).toString("base64"); - return - - {showName && item.name} - - } else { - return + if (item.url.startsWith("mailto:") && !item.interaction?.copyText) { + const encodedEmail = Buffer.from(item.url.replace("mailto:", "")).toString("base64"); + return ( +
+ + + {showName && item.name} + + {item.interaction?.qrCode && ( + + )} +
+ ); + } + + return ( +
+ {showName && item.name} - } - } - )} - {profileConfig.links.length == 1 && (function(item){ - if (item.url.startsWith("mailto:")) { - const encodedEmail = Buffer.from(item.url.replace("mailto:", "")).toString("base64"); - return - - {item.name} - - } else { - return - - {item.name} - - } - })(profileConfig.links[0])} + {item.interaction?.qrCode && ( + + )} +
+ ); + })}
+ + diff --git a/src/config/profileConfig.ts b/src/config/profileConfig.ts index 23fdd3e9a4..0cf9442ede 100644 --- a/src/config/profileConfig.ts +++ b/src/config/profileConfig.ts @@ -20,13 +20,37 @@ export const profileConfig: ProfileConfig = { // 如果想使用尚未包含相应的图标集,则需要安装它 // `pnpm add @iconify-json/` // showName: true 时显示图标和名称,false 时只显示图标 + // interaction 可配置悬停/点击交互选项: + // - qrCode: 悬停/聚焦时弹出的二维码图片路径 (如 "/assets/qr-code/wechat.png") + // - qrCodeAlt: 二维码图片的 Alt 描述文本 + // - copyText: 点击时自动复制到剪贴板的文本 (如微信号/QQ号) + // - openDelayMs: 点击后延迟打开外链的时间(毫秒,默认 0) + links: [ { name: "qq", icon: "fa7-brands:qq", url: "https://qm.qq.com/q/ZGsFa8qX2G", showName: false, + /* interaction: { + copyText: "qq群:xxxx 网站:xxxx 欢迎扩列~", + openDelayMs: 0, + qrCode: "https://example.com/assets/qq_qrcode.png", + qrCodeAlt: "QQ 二维码", + }, */ }, + /* { + name: "微信", + icon: "fa7-brands:weixin", + url: "weixin://", + showName: false, + interaction: { + copyText: "微信:xxx 网站:xxxx 欢迎扩列~", + openDelayMs: 3000, + qrCode: "https://example.com/assets/wechat_qrcode.png", + qrCodeAlt: "微信二维码", + }, + }, */ { name: "GitHub", icon: "fa7-brands:github", diff --git a/src/types/profileConfig.ts b/src/types/profileConfig.ts index d17289af2d..81d628d7a8 100644 --- a/src/types/profileConfig.ts +++ b/src/types/profileConfig.ts @@ -1,11 +1,21 @@ +export type ProfileLinkInteraction = { + qrCode?: string; + qrCodeAlt?: string; + copyText?: string; + openDelayMs?: number; +}; + +export type ProfileLink = { + name: string; + url: string; + icon: string; + showName?: boolean; + interaction?: ProfileLinkInteraction; +}; + export type ProfileConfig = { avatar?: string; name: string; bio?: string; - links: { - name: string; - url: string; - icon: string; - showName?: boolean; - }[]; + links: ProfileLink[]; }; diff --git a/src/utils/navbar-social-interaction.ts b/src/utils/navbar-social-interaction.ts new file mode 100644 index 0000000000..9d1df0d32c --- /dev/null +++ b/src/utils/navbar-social-interaction.ts @@ -0,0 +1,175 @@ +export type Rect = { + top: number; + right: number; + bottom: number; + left: number; + width: number; + height: number; +}; + +export type Size = { + width: number; + height: number; +}; + +export type QrPopoverPlacement = { + placement: "top" | "bottom"; + top: number; + left: number; +}; + +export type DelayedLinkOptions = { + copyText: string; + href: string; + openDelayMs: number; +}; + +export type DelayedLinkNotification = + | { + kind: "success"; + copyText: string; + openDelayMs: number; + secondsRemaining: number; + } + | { kind: "failure"; copyText: string } + | { kind: "complete"; copyText: string; openDelayMs: number }; + +export type DelayedLinkDependencies = { + copy: (text: string) => Promise; + notify: (notification: DelayedLinkNotification) => void; + open: (href: string) => void; + setTimeout: (callback: () => void, delay: number) => number; + clearTimeout: (id: number) => void; + setInterval: (callback: () => void, delay: number) => number; + clearInterval: (id: number) => void; +}; + +export type DelayedLinkController = { + activate: (options: DelayedLinkOptions) => Promise; + cancel: () => void; +}; + +export function calculateQrPopoverPosition( + trigger: Rect, + popover: Size, + viewport: Size, + gap = 8, + margin = 12, +): QrPopoverPlacement { + const spaceBelow = viewport.height - trigger.bottom; + const spaceAbove = trigger.top; + const placement = + spaceBelow < popover.height + gap && spaceAbove > spaceBelow + ? "top" + : "bottom"; + const top = + placement === "top" + ? trigger.top - popover.height - gap + : trigger.bottom + gap; + const centeredLeft = trigger.left + (trigger.width - popover.width) / 2; + const maxLeft = Math.max(margin, viewport.width - popover.width - margin); + const left = Math.min(Math.max(centeredLeft, margin), maxLeft); + + return { placement, top, left }; +} + +export async function copyWithFallback( + primary: (() => Promise) | undefined, + fallback: () => boolean, +): Promise { + if (primary) { + try { + await primary(); + return true; + } catch { + // Continue with legacy clipboard fallback. + } + } + + try { + return fallback(); + } catch { + return false; + } +} + +export function createDelayedLinkController( + dependencies: DelayedLinkDependencies, +): DelayedLinkController { + let timeoutId: number | undefined; + let intervalId: number | undefined; + let activationId = 0; + + function clearTimers(): void { + if (timeoutId !== undefined) { + dependencies.clearTimeout(timeoutId); + timeoutId = undefined; + } + if (intervalId !== undefined) { + dependencies.clearInterval(intervalId); + intervalId = undefined; + } + } + + function cancel(): void { + activationId += 1; + clearTimers(); + } + + async function activate(options: DelayedLinkOptions): Promise { + activationId += 1; + const currentActivation = activationId; + clearTimers(); + + const copied = await dependencies.copy(options.copyText); + if (currentActivation !== activationId) return; + + if (!copied) { + dependencies.notify({ kind: "failure", copyText: options.copyText }); + return; + } + + if (options.openDelayMs <= 0) { + dependencies.notify({ + kind: "complete", + copyText: options.copyText, + openDelayMs: 0, + }); + dependencies.open(options.href); + return; + } + + let secondsRemaining = Math.max(1, Math.ceil(options.openDelayMs / 1000)); + dependencies.notify({ + kind: "success", + copyText: options.copyText, + openDelayMs: options.openDelayMs, + secondsRemaining, + }); + + intervalId = dependencies.setInterval(() => { + if (secondsRemaining <= 0) return; + secondsRemaining -= 1; + if (secondsRemaining > 0) { + dependencies.notify({ + kind: "success", + copyText: options.copyText, + openDelayMs: options.openDelayMs, + secondsRemaining, + }); + } + }, 1000); + + timeoutId = dependencies.setTimeout(() => { + clearTimers(); + dependencies.notify({ + kind: "complete", + copyText: options.copyText, + openDelayMs: options.openDelayMs, + }); + dependencies.open(options.href); + }, options.openDelayMs); + } + + return { activate, cancel }; +}