fix all bug
This commit is contained in:
parent
b12474a232
commit
3b113feb3a
|
|
@ -10,7 +10,8 @@
|
|||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-useanimations": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
|
@ -2617,6 +2618,12 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lottie-web": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
|
|
@ -2916,6 +2923,19 @@
|
|||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-useanimations": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/react-useanimations/-/react-useanimations-2.10.0.tgz",
|
||||
"integrity": "sha512-MzGNv8vkvb6qEvMBCj+O6nUloUHSJRubMAH3uE7J4M+pjt5ud5xDaXBrQgv5GbvBg29XzviKWHTfvvkofDEu+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lottie-web": "^5.5.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-useanimations": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { AccountAvatar } from "../ui/AccountAvatar";
|
||||
import { AppIcon } from "../ui/AppIcons";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { memberRoleSummary } from "../../lib/memberRole";
|
||||
import { can, Permission } from "../../lib/permissions";
|
||||
|
|
@ -49,7 +50,7 @@ export function AccountMenu() {
|
|||
<AccountAvatar account={avatarAccount} className="hb-account__avatar" />
|
||||
<span className="hb-account__name">{name}</span>
|
||||
<span className="hb-account__chev" aria-hidden>
|
||||
▾
|
||||
<AppIcon name="chevron" size={18} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { AppNotification } from "../../domain/types";
|
|||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { pathForNotification } from "../../lib/nav";
|
||||
import { formatLocalDateTime, formatTimeAgo } from "../../lib/time";
|
||||
import { Button } from "../ui";
|
||||
import { AppIcon, Button } from "../ui";
|
||||
|
||||
const PREVIEW = 5;
|
||||
|
||||
|
|
@ -117,21 +117,7 @@ export function BellMenu() {
|
|||
title={t("topbar.notifications")}
|
||||
>
|
||||
<span className="hb-bell__icon" aria-hidden>
|
||||
{/* 簡化鈴鐺:幾何,不用 emoji */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M6 9.5a6 6 0 1 1 12 0c0 3.2 1.2 4.6 1.8 5.2H4.2C4.8 14.1 6 12.7 6 9.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 18.2a2 2 0 0 0 4 0"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<AppIcon name="bell" size={18} />
|
||||
</span>
|
||||
{unread > 0 ? (
|
||||
<span className="hb-bell__badge">{unread > 9 ? "9+" : unread}</span>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,11 @@ export function MobileDock() {
|
|||
<AppIcon name={item.key} size={20} />
|
||||
</span>
|
||||
<span className="hb-dock-more__row-label">{t(item.labelKey)}</span>
|
||||
{active ? <span className="hb-dock-more__check" aria-hidden>✓</span> : null}
|
||||
{active ? (
|
||||
<span className="hb-dock-more__check" aria-hidden>
|
||||
<AppIcon name="check" size={16} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { AppIcon } from "../ui/AppIcons";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import {
|
||||
PAGE_HELP_RELATED,
|
||||
|
|
@ -150,7 +151,7 @@ export function PageHelpTrigger({ className = "" }: { className?: string }) {
|
|||
aria-haspopup="dialog"
|
||||
title={`${t("help.open")} (? )`}
|
||||
>
|
||||
<span aria-hidden>?</span>
|
||||
<AppIcon name="help" size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Brand, BrandProduct, CostPreview, ExploreResult, WatchTermSuggesti
|
|||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { useFormatApiError } from "../../lib/apiErrors";
|
||||
import { checkThreadsTerm, isThreadsSearchable, normalizeSearchTerm } from "../../lib/threadsTerm";
|
||||
import { Badge, Button, Input } from "../ui";
|
||||
import { AppIcon, Badge, Button, Input } from "../ui";
|
||||
import { CostPreviewDialog } from "./CostPreviewDialog";
|
||||
|
||||
const MAX_TERMS = 6;
|
||||
|
|
@ -188,7 +188,7 @@ export function ExplorePanel({ onExplored }: { onExplored: () => void }) {
|
|||
>
|
||||
<Badge tone="brand">{term}</Badge>
|
||||
<span className="hb-radar-explore__chip-x" aria-hidden>
|
||||
×
|
||||
<AppIcon name="close" size={14} reverse />
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { Button } from "../ui";
|
||||
import { AppIcon, Button } from "../ui";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import {
|
||||
compressDataUrlForUpload,
|
||||
|
|
@ -149,7 +149,7 @@ export function ImageAttach({
|
|||
disabled={disabled}
|
||||
onClick={() => remove(img.id)}
|
||||
>
|
||||
×
|
||||
<AppIcon name="close" size={14} reverse />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppIcon } from "./AppIcons";
|
||||
|
||||
describe("AppIcon", () => {
|
||||
it("injects an svg from useanimations data", () => {
|
||||
const { container } = render(
|
||||
<button type="button">
|
||||
<AppIcon name="bell" size={24} />
|
||||
</button>,
|
||||
);
|
||||
const host = container.querySelector(".hb-app-icon");
|
||||
expect(host).toBeTruthy();
|
||||
expect(host?.querySelector("svg")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts animating when the parent is hovered", async () => {
|
||||
const { container } = render(
|
||||
<button type="button">
|
||||
<AppIcon name="bell" size={24} />
|
||||
</button>,
|
||||
);
|
||||
const host = container.querySelector(".hb-app-icon");
|
||||
const before = host?.innerHTML ?? "";
|
||||
host?.closest("button")?.dispatchEvent(new Event("pointerenter", { bubbles: true }));
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, 80);
|
||||
});
|
||||
const after = host?.innerHTML ?? "";
|
||||
expect(after.length).toBeGreaterThan(0);
|
||||
expect(after !== before || after.includes("transform")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,210 +1,197 @@
|
|||
import type { ReactNode, SVGProps } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import lottieMod from "lottie-web";
|
||||
import archive from "react-useanimations/lib/archive";
|
||||
import arrowDownCircle from "react-useanimations/lib/arrowDownCircle";
|
||||
import bookmark from "react-useanimations/lib/bookmark";
|
||||
import calendar from "react-useanimations/lib/calendar";
|
||||
import checkmark from "react-useanimations/lib/checkmark";
|
||||
import copy from "react-useanimations/lib/copy";
|
||||
import edit from "react-useanimations/lib/edit";
|
||||
import explore from "react-useanimations/lib/explore";
|
||||
import folder from "react-useanimations/lib/folder";
|
||||
import help from "react-useanimations/lib/help";
|
||||
import lock from "react-useanimations/lib/lock";
|
||||
import mail from "react-useanimations/lib/mail";
|
||||
import menu2 from "react-useanimations/lib/menu2";
|
||||
import notification2 from "react-useanimations/lib/notification2";
|
||||
import plusToX from "react-useanimations/lib/plusToX";
|
||||
import searchToX from "react-useanimations/lib/searchToX";
|
||||
import share from "react-useanimations/lib/share";
|
||||
import skipForward from "react-useanimations/lib/skipForward";
|
||||
import star from "react-useanimations/lib/star";
|
||||
import thumbUp from "react-useanimations/lib/thumbUp";
|
||||
import userPlus from "react-useanimations/lib/userPlus";
|
||||
import visibility from "react-useanimations/lib/visibility";
|
||||
import type { NavKey } from "../../lib/nav";
|
||||
|
||||
export type AppIconName = NavKey | "more" | "spark" | "send";
|
||||
type LottieItem = {
|
||||
play: () => void;
|
||||
destroy: () => void;
|
||||
setDirection: (direction: number) => void;
|
||||
goToAndStop: (value: number, isFrame?: boolean) => void;
|
||||
totalFrames: number;
|
||||
};
|
||||
|
||||
type LottiePlayer = {
|
||||
loadAnimation: (opts: {
|
||||
container: Element;
|
||||
renderer: "svg";
|
||||
loop: boolean;
|
||||
autoplay: boolean;
|
||||
animationData: unknown;
|
||||
}) => LottieItem;
|
||||
};
|
||||
|
||||
type Glyph = { animationData: unknown; animationKey: string };
|
||||
|
||||
/** CJS 套件在 Vite 下 default 可能包一層 `{ default: x }`。 */
|
||||
function unwrapDefault<T>(mod: T | { default: T }): T {
|
||||
if (typeof mod === "function") return mod;
|
||||
if (mod && typeof mod === "object" && "default" in mod) {
|
||||
return (mod as { default: T }).default;
|
||||
}
|
||||
return mod as T;
|
||||
}
|
||||
|
||||
function asGlyph(mod: unknown): Glyph {
|
||||
const inner = unwrapDefault(mod) as Glyph;
|
||||
return {
|
||||
animationKey: inner.animationKey,
|
||||
animationData: unwrapDefault(inner.animationData),
|
||||
};
|
||||
}
|
||||
|
||||
const lottie = unwrapDefault(lottieMod) as LottiePlayer;
|
||||
|
||||
export type AppIconName =
|
||||
| NavKey
|
||||
| "more"
|
||||
| "spark"
|
||||
| "send"
|
||||
| "bell"
|
||||
| "visibility"
|
||||
| "chevron"
|
||||
| "help"
|
||||
| "close"
|
||||
| "check";
|
||||
|
||||
type Props = {
|
||||
name: AppIconName;
|
||||
size?: number;
|
||||
className?: string;
|
||||
} & Omit<SVGProps<SVGSVGElement>, "name">;
|
||||
/** 密碼眼睛等:點一下才播,不要 hover 就劃掉 */
|
||||
play?: "hover" | "click";
|
||||
/** 一開始停在最後一格(例如 close 顯示 X) */
|
||||
reverse?: boolean;
|
||||
};
|
||||
|
||||
const glyphs: Record<AppIconName, Glyph> = {
|
||||
today: asGlyph(calendar),
|
||||
crew: asGlyph(userPlus),
|
||||
studio: asGlyph(edit),
|
||||
scout: asGlyph(searchToX),
|
||||
radar: asGlyph(explore),
|
||||
crm: asGlyph(archive),
|
||||
outbox: asGlyph(mail),
|
||||
jobs: asGlyph(folder),
|
||||
brands: asGlyph(bookmark),
|
||||
policy: asGlyph(lock),
|
||||
playbooks: asGlyph(copy),
|
||||
insights: asGlyph(thumbUp),
|
||||
benchmark: asGlyph(star),
|
||||
utm: asGlyph(share),
|
||||
more: asGlyph(menu2),
|
||||
spark: asGlyph(star),
|
||||
send: asGlyph(skipForward),
|
||||
bell: asGlyph(notification2),
|
||||
visibility: asGlyph(visibility),
|
||||
chevron: asGlyph(arrowDownCircle),
|
||||
help: asGlyph(help),
|
||||
close: asGlyph(plusToX),
|
||||
check: asGlyph(checkmark),
|
||||
};
|
||||
|
||||
/**
|
||||
* 精緻線稿 icon:圓潤端點、雙層深度、細微星芒
|
||||
* 主線 currentColor;星芒用主題 teal / coral,適配 light / dark。
|
||||
* 用 react-useanimations 的 Lottie 檔,但自己播。
|
||||
* 原套件事件只綁在 18px 方塊上,hover 側欄整列不會動;cleanup 也抓不到 instance。
|
||||
*/
|
||||
export function AppIcon({ name, size = 22, className = "", ...rest }: Props) {
|
||||
export function AppIcon({
|
||||
name,
|
||||
size = 22,
|
||||
className = "",
|
||||
play = "hover",
|
||||
reverse = false,
|
||||
}: Props) {
|
||||
const hostRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const glyph = glyphs[name];
|
||||
if (!host || !glyph?.animationData || typeof lottie?.loadAnimation !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
host.replaceChildren();
|
||||
const anim = lottie.loadAnimation({
|
||||
container: host,
|
||||
renderer: "svg",
|
||||
loop: false,
|
||||
autoplay: false,
|
||||
animationData: glyph.animationData,
|
||||
});
|
||||
|
||||
if (reverse && anim.totalFrames > 0) {
|
||||
anim.goToAndStop(anim.totalFrames - 1, true);
|
||||
}
|
||||
|
||||
const target = host.closest("a, button, [data-icon-host]") ?? host;
|
||||
let direction = reverse ? -1 : 1;
|
||||
|
||||
const playForward = () => {
|
||||
if (reverse) {
|
||||
anim.goToAndStop(0, true);
|
||||
}
|
||||
anim.setDirection(1);
|
||||
anim.play();
|
||||
};
|
||||
const playBack = () => {
|
||||
if (reverse) {
|
||||
anim.goToAndStop(Math.max(0, anim.totalFrames - 1), true);
|
||||
return;
|
||||
}
|
||||
anim.setDirection(-1);
|
||||
anim.play();
|
||||
};
|
||||
const playToggle = () => {
|
||||
anim.setDirection(direction);
|
||||
anim.play();
|
||||
direction *= -1;
|
||||
};
|
||||
|
||||
if (play === "click") {
|
||||
target.addEventListener("click", playToggle);
|
||||
} else {
|
||||
target.addEventListener("pointerenter", playForward);
|
||||
target.addEventListener("pointerleave", playBack);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (play === "click") {
|
||||
target.removeEventListener("click", playToggle);
|
||||
} else {
|
||||
target.removeEventListener("pointerenter", playForward);
|
||||
target.removeEventListener("pointerleave", playBack);
|
||||
}
|
||||
anim.destroy();
|
||||
};
|
||||
}, [name, play, reverse]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
<span
|
||||
ref={hostRef}
|
||||
className={`hb-app-icon ${className}`.trim()}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden
|
||||
{...rest}
|
||||
>
|
||||
{glyphs[name]}
|
||||
</svg>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const stroke = {
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
|
||||
/** 小星芒:點綴色走主題 magic / accent */
|
||||
function Spark({
|
||||
x = 18,
|
||||
y = 5,
|
||||
s = 1,
|
||||
tone = "magic",
|
||||
}: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
s?: number;
|
||||
tone?: "magic" | "accent";
|
||||
}) {
|
||||
// magic 不用 --hb-magic:那是次色奶油黃,當線條放在淺底上幾乎看不見。
|
||||
const color = tone === "accent" ? "var(--hb-accent-warm)" : "var(--hb-brand-deep)";
|
||||
return (
|
||||
<g opacity={0.9} transform={`translate(${x} ${y}) scale(${s})`}>
|
||||
<path
|
||||
d="M0-2.2V2.2M-2.2 0H2.2"
|
||||
stroke={color}
|
||||
strokeWidth={1.35}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const glyphs: Record<AppIconName, ReactNode> = {
|
||||
// 今日:圓角日曆 + 星
|
||||
today: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="3.5" {...stroke} />
|
||||
<path d="M8 3.75v3.1M16 3.75v3.1M3.5 10.25h17" {...stroke} />
|
||||
<circle cx="9.2" cy="14.6" r="1.05" fill="currentColor" opacity={0.9} />
|
||||
<Spark x={16.2} y={14.4} s={0.72} />
|
||||
</>
|
||||
),
|
||||
// 帳號:雙圓 + 柔弧(島民)
|
||||
crew: (
|
||||
<>
|
||||
<circle cx="9" cy="8.6" r="3" {...stroke} />
|
||||
<path d="M4 18.2c.55-2.9 2.7-4.35 5-4.35 2.3 0 4.45 1.45 5 4.35" {...stroke} />
|
||||
<circle cx="16.6" cy="9.4" r="2.35" {...stroke} opacity={0.85} />
|
||||
<path d="M14.2 18.2c.35-1.85 1.55-2.85 3.15-2.85 1.35 0 2.45.7 3.05 1.9" {...stroke} opacity={0.85} />
|
||||
<Spark x={18.8} y={5.2} s={0.55} tone="accent" />
|
||||
</>
|
||||
),
|
||||
// 創作:羽毛筆 + 星
|
||||
studio: (
|
||||
<>
|
||||
<path
|
||||
d="M5.2 19.2 14.6 5.4a1.7 1.7 0 0 1 2.45-.15l1.9 1.9a1.7 1.7 0 0 1-.15 2.45L9.3 19.2H5.2z"
|
||||
{...stroke}
|
||||
/>
|
||||
<path d="M12.7 7.9 16.1 11.3" {...stroke} />
|
||||
<path d="M5.2 19.2 8.1 18.3" {...stroke} opacity={0.7} />
|
||||
<Spark x={18.5} y={5.5} s={0.7} tone="accent" />
|
||||
</>
|
||||
),
|
||||
// 海巡:水晶球 / 羅盤弧 + 十字光
|
||||
scout: (
|
||||
<>
|
||||
<circle cx="11.2" cy="11.2" r="6.4" {...stroke} />
|
||||
<circle cx="11.2" cy="11.2" r="2.15" {...stroke} opacity={0.55} />
|
||||
<path d="M11.2 6.4v9.6M6.4 11.2h9.6" {...stroke} opacity={0.55} />
|
||||
<path d="M15.8 15.8 20.2 20.2" {...stroke} />
|
||||
<Spark x={18.2} y={6.2} s={0.62} />
|
||||
</>
|
||||
),
|
||||
// 雷達:同心圓掃描
|
||||
radar: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="7.2" {...stroke} />
|
||||
<circle cx="12" cy="12" r="4.1" {...stroke} opacity={0.65} />
|
||||
<circle cx="12" cy="12" r="1.2" fill="currentColor" opacity={0.85} />
|
||||
<path d="M12 12 17.2 7.4" {...stroke} />
|
||||
<Spark x={17.6} y={6.8} s={0.55} tone="accent" />
|
||||
</>
|
||||
),
|
||||
// 名單:看板卡片
|
||||
crm: (
|
||||
<>
|
||||
<rect x="3.8" y="5" width="5.2" height="14" rx="1.4" {...stroke} />
|
||||
<rect x="9.4" y="5" width="5.2" height="14" rx="1.4" {...stroke} opacity={0.85} />
|
||||
<rect x="15" y="5" width="5.2" height="14" rx="1.4" {...stroke} opacity={0.7} />
|
||||
<Spark x={18.4} y={7.2} s={0.5} />
|
||||
</>
|
||||
),
|
||||
// 發送:紙飛機 + 軌跡星
|
||||
outbox: (
|
||||
<>
|
||||
<path d="M4.2 11.1 19.6 4.6 12.2 19.8l-1.7-6.3z" {...stroke} />
|
||||
<path d="M10.5 13.5 19.6 4.6" {...stroke} opacity={0.65} />
|
||||
<Spark x={7.2} y={7.5} s={0.5} tone="accent" />
|
||||
</>
|
||||
),
|
||||
// 任務:魔法卷軸/手提袋簡化
|
||||
jobs: (
|
||||
<>
|
||||
<rect x="4.2" y="7.2" width="15.6" height="12.2" rx="2.6" {...stroke} />
|
||||
<path d="M9 7.2V5.9a2.1 2.1 0 0 1 2.1-2.1h1.8A2.1 2.1 0 0 1 15 5.9v1.3" {...stroke} />
|
||||
<path d="M4.2 12.1h15.6" {...stroke} opacity={0.55} />
|
||||
<Spark x={17.5} y={15.8} s={0.55} />
|
||||
</>
|
||||
),
|
||||
// 品牌:旗幟堡壘簡化 + 星
|
||||
brands: (
|
||||
<>
|
||||
<path d="M6.2 20V9.1L12 5.2 17.8 9.1V20" {...stroke} />
|
||||
<path d="M10.2 20v-4.6h3.6V20" {...stroke} />
|
||||
<path d="M12 9.6v2.4" {...stroke} opacity={0.55} />
|
||||
<Spark x={17.8} y={6.4} s={0.58} tone="accent" />
|
||||
</>
|
||||
),
|
||||
// 商機政策:盾牌 + 核取,代表全域回覆與篩選規則
|
||||
policy: (
|
||||
<>
|
||||
<path d="M12 3.8 19 6.5v5.2c0 4.3-2.7 7.2-7 8.7-4.3-1.5-7-4.4-7-8.7V6.5z" {...stroke} />
|
||||
<path d="m8.7 12 2.1 2.1 4.5-4.6" {...stroke} />
|
||||
</>
|
||||
),
|
||||
playbooks: (
|
||||
<>
|
||||
<path d="M6 5.5h9.5a2 2 0 0 1 2 2V19l-3.2-1.8L11 19l-3.3-1.8L4.5 19V7.5a2 2 0 0 1 2-2z" {...stroke} />
|
||||
<path d="M8.2 9.5h7.2M8.2 12.2h7.2M8.2 14.9h4.5" {...stroke} opacity={0.7} />
|
||||
</>
|
||||
),
|
||||
insights: (
|
||||
<>
|
||||
<path d="M5 18.5V10.2M10 18.5V6.5M15 18.5v-5.2M20 18.5V8.8" {...stroke} />
|
||||
<path d="M4.5 18.8h16" {...stroke} opacity={0.5} />
|
||||
<Spark x={18.5} y={6.2} s={0.55} />
|
||||
</>
|
||||
),
|
||||
benchmark: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="7" {...stroke} />
|
||||
<circle cx="12" cy="12" r="3.2" {...stroke} opacity={0.7} />
|
||||
<path d="M12 5v2.2M12 16.8V19M5 12h2.2M16.8 12H19" {...stroke} opacity={0.55} />
|
||||
</>
|
||||
),
|
||||
utm: (
|
||||
<>
|
||||
<path d="M8 12.5h9.5" {...stroke} />
|
||||
<path d="M14.2 8.8 18.5 12.5 14.2 16.2" {...stroke} />
|
||||
<circle cx="6.2" cy="12.5" r="2" {...stroke} />
|
||||
</>
|
||||
),
|
||||
more: (
|
||||
<>
|
||||
<circle cx="6.5" cy="12" r="1.45" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="1.45" fill="currentColor" />
|
||||
<circle cx="17.5" cy="12" r="1.45" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
spark: (
|
||||
<>
|
||||
<Spark x={12} y={12} s={1.35} />
|
||||
<circle cx="12" cy="12" r="1.1" fill="currentColor" opacity={0.35} />
|
||||
</>
|
||||
),
|
||||
// 送出:置中紙飛機(小圓鈕專用,無星芒避免破圖感)
|
||||
send: (
|
||||
<>
|
||||
<path
|
||||
d="M4 11.5 19.5 4.2a.7.7 0 0 1 .95.9L13.6 20.3a.7.7 0 0 1-1.28.08L9.7 14.1 4 11.5z"
|
||||
{...stroke}
|
||||
/>
|
||||
<path d="M9.7 14.1 19.7 5" {...stroke} opacity={0.55} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, type InputHTMLAttributes } from "react";
|
||||
import { AppIcon } from "./AppIcons";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string;
|
||||
|
|
@ -54,7 +55,7 @@ export function Input({
|
|||
aria-pressed={visible}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
>
|
||||
<PasswordEyeIcon hidden={visible} />
|
||||
<AppIcon name="visibility" size={20} play="click" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
|
|
@ -68,26 +69,3 @@ export function Input({
|
|||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordEyeIcon({ hidden }: { hidden: boolean }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M3.5 12s3.2-6 8.5-6 8.5 6 8.5 6-3.2 6-8.5 6-8.5-6-8.5-6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="2.4" stroke="currentColor" strokeWidth="1.5" />
|
||||
{hidden ? (
|
||||
<path
|
||||
d="M4 20 20 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1052,7 +1052,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
title={t("inspire.unpinTitle")}
|
||||
aria-label={t("inspire.unpinTitle")}
|
||||
>
|
||||
×
|
||||
<AppIcon name="close" size={14} reverse />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1317,7 +1317,7 @@ function ElementRow({
|
|||
aria-label={t("inspire.deleteAria")}
|
||||
onClick={onRemove}
|
||||
>
|
||||
×
|
||||
<AppIcon name="close" size={16} reverse />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2301,8 +2301,6 @@ svg {
|
|||
.hb-inspire-send-btn .hb-app-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
/* 紙飛機視覺重心略偏左,微調置中 */
|
||||
transform: translate(1px, 0.5px);
|
||||
}
|
||||
|
||||
.hb-inspire-send-btn__dots {
|
||||
|
|
|
|||
|
|
@ -1211,10 +1211,28 @@ a.hb-topbar__chip:hover {
|
|||
}
|
||||
|
||||
.hb-dock__icon svg,
|
||||
.hb-app-icon {
|
||||
.hb-app-icon,
|
||||
.hb-app-icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hb-app-icon {
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hb-app-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hb-app-icon path[stroke],
|
||||
.hb-app-icon circle[stroke],
|
||||
.hb-app-icon rect[stroke] {
|
||||
stroke: currentColor !important;
|
||||
}
|
||||
|
||||
.hb-dock-more__ico {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,19 @@ import "@testing-library/jest-dom/vitest";
|
|||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
/** lottie-web 載入時會摸 canvas;jsdom 的 getContext 會丟 Not implemented。 */
|
||||
HTMLCanvasElement.prototype.getContext = function getContext(this: HTMLCanvasElement) {
|
||||
return {
|
||||
fillStyle: "",
|
||||
fillRect() {},
|
||||
drawImage() {},
|
||||
getImageData() {
|
||||
return { data: new Uint8ClampedArray(4) };
|
||||
},
|
||||
canvas: this,
|
||||
};
|
||||
} as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue