fix all bug
This commit is contained in:
parent
b12474a232
commit
3b113feb3a
|
|
@ -10,7 +10,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|
@ -2617,6 +2618,12 @@
|
||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
|
|
@ -2916,6 +2923,19 @@
|
||||||
"react-dom": ">=18"
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@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 { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { AccountAvatar } from "../ui/AccountAvatar";
|
import { AccountAvatar } from "../ui/AccountAvatar";
|
||||||
|
import { AppIcon } from "../ui/AppIcons";
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
import { memberRoleSummary } from "../../lib/memberRole";
|
import { memberRoleSummary } from "../../lib/memberRole";
|
||||||
import { can, Permission } from "../../lib/permissions";
|
import { can, Permission } from "../../lib/permissions";
|
||||||
|
|
@ -49,7 +50,7 @@ export function AccountMenu() {
|
||||||
<AccountAvatar account={avatarAccount} className="hb-account__avatar" />
|
<AccountAvatar account={avatarAccount} className="hb-account__avatar" />
|
||||||
<span className="hb-account__name">{name}</span>
|
<span className="hb-account__name">{name}</span>
|
||||||
<span className="hb-account__chev" aria-hidden>
|
<span className="hb-account__chev" aria-hidden>
|
||||||
▾
|
<AppIcon name="chevron" size={18} />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { AppNotification } from "../../domain/types";
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
import { pathForNotification } from "../../lib/nav";
|
import { pathForNotification } from "../../lib/nav";
|
||||||
import { formatLocalDateTime, formatTimeAgo } from "../../lib/time";
|
import { formatLocalDateTime, formatTimeAgo } from "../../lib/time";
|
||||||
import { Button } from "../ui";
|
import { AppIcon, Button } from "../ui";
|
||||||
|
|
||||||
const PREVIEW = 5;
|
const PREVIEW = 5;
|
||||||
|
|
||||||
|
|
@ -117,21 +117,7 @@ export function BellMenu() {
|
||||||
title={t("topbar.notifications")}
|
title={t("topbar.notifications")}
|
||||||
>
|
>
|
||||||
<span className="hb-bell__icon" aria-hidden>
|
<span className="hb-bell__icon" aria-hidden>
|
||||||
{/* 簡化鈴鐺:幾何,不用 emoji */}
|
<AppIcon name="bell" size={18} />
|
||||||
<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>
|
|
||||||
</span>
|
</span>
|
||||||
{unread > 0 ? (
|
{unread > 0 ? (
|
||||||
<span className="hb-bell__badge">{unread > 9 ? "9+" : unread}</span>
|
<span className="hb-bell__badge">{unread > 9 ? "9+" : unread}</span>
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,11 @@ export function MobileDock() {
|
||||||
<AppIcon name={item.key} size={20} />
|
<AppIcon name={item.key} size={20} />
|
||||||
</span>
|
</span>
|
||||||
<span className="hb-dock-more__row-label">{t(item.labelKey)}</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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { AppIcon } from "../ui/AppIcons";
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
import {
|
import {
|
||||||
PAGE_HELP_RELATED,
|
PAGE_HELP_RELATED,
|
||||||
|
|
@ -150,7 +151,7 @@ export function PageHelpTrigger({ className = "" }: { className?: string }) {
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
title={`${t("help.open")} (? )`}
|
title={`${t("help.open")} (? )`}
|
||||||
>
|
>
|
||||||
<span aria-hidden>?</span>
|
<AppIcon name="help" size={18} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Brand, BrandProduct, CostPreview, ExploreResult, WatchTermSuggesti
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
import { useFormatApiError } from "../../lib/apiErrors";
|
import { useFormatApiError } from "../../lib/apiErrors";
|
||||||
import { checkThreadsTerm, isThreadsSearchable, normalizeSearchTerm } from "../../lib/threadsTerm";
|
import { checkThreadsTerm, isThreadsSearchable, normalizeSearchTerm } from "../../lib/threadsTerm";
|
||||||
import { Badge, Button, Input } from "../ui";
|
import { AppIcon, Badge, Button, Input } from "../ui";
|
||||||
import { CostPreviewDialog } from "./CostPreviewDialog";
|
import { CostPreviewDialog } from "./CostPreviewDialog";
|
||||||
|
|
||||||
const MAX_TERMS = 6;
|
const MAX_TERMS = 6;
|
||||||
|
|
@ -188,7 +188,7 @@ export function ExplorePanel({ onExplored }: { onExplored: () => void }) {
|
||||||
>
|
>
|
||||||
<Badge tone="brand">{term}</Badge>
|
<Badge tone="brand">{term}</Badge>
|
||||||
<span className="hb-radar-explore__chip-x" aria-hidden>
|
<span className="hb-radar-explore__chip-x" aria-hidden>
|
||||||
×
|
<AppIcon name="close" size={14} reverse />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Button } from "../ui";
|
import { AppIcon, Button } from "../ui";
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
import {
|
import {
|
||||||
compressDataUrlForUpload,
|
compressDataUrlForUpload,
|
||||||
|
|
@ -149,7 +149,7 @@ export function ImageAttach({
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => remove(img.id)}
|
onClick={() => remove(img.id)}
|
||||||
>
|
>
|
||||||
×
|
<AppIcon name="close" size={14} reverse />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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";
|
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 = {
|
type Props = {
|
||||||
name: AppIconName;
|
name: AppIconName;
|
||||||
size?: number;
|
size?: number;
|
||||||
className?: string;
|
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:圓潤端點、雙層深度、細微星芒
|
* 用 react-useanimations 的 Lottie 檔,但自己播。
|
||||||
* 主線 currentColor;星芒用主題 teal / coral,適配 light / dark。
|
* 原套件事件只綁在 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 (
|
return (
|
||||||
<svg
|
<span
|
||||||
width={size}
|
ref={hostRef}
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className={`hb-app-icon ${className}`.trim()}
|
className={`hb-app-icon ${className}`.trim()}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
aria-hidden
|
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 { useState, type InputHTMLAttributes } from "react";
|
||||||
|
import { AppIcon } from "./AppIcons";
|
||||||
|
|
||||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
@ -54,7 +55,7 @@ export function Input({
|
||||||
aria-pressed={visible}
|
aria-pressed={visible}
|
||||||
onClick={() => setVisible((v) => !v)}
|
onClick={() => setVisible((v) => !v)}
|
||||||
>
|
>
|
||||||
<PasswordEyeIcon hidden={visible} />
|
<AppIcon name="visibility" size={20} play="click" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -68,26 +69,3 @@ export function Input({
|
||||||
</label>
|
</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")}
|
title={t("inspire.unpinTitle")}
|
||||||
aria-label={t("inspire.unpinTitle")}
|
aria-label={t("inspire.unpinTitle")}
|
||||||
>
|
>
|
||||||
×
|
<AppIcon name="close" size={14} reverse />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1317,7 +1317,7 @@ function ElementRow({
|
||||||
aria-label={t("inspire.deleteAria")}
|
aria-label={t("inspire.deleteAria")}
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
>
|
>
|
||||||
×
|
<AppIcon name="close" size={16} reverse />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2301,8 +2301,6 @@ svg {
|
||||||
.hb-inspire-send-btn .hb-app-icon {
|
.hb-inspire-send-btn .hb-app-icon {
|
||||||
display: block;
|
display: block;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
/* 紙飛機視覺重心略偏左,微調置中 */
|
|
||||||
transform: translate(1px, 0.5px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-inspire-send-btn__dots {
|
.hb-inspire-send-btn__dots {
|
||||||
|
|
|
||||||
|
|
@ -1211,10 +1211,28 @@ a.hb-topbar__chip:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-dock__icon svg,
|
.hb-dock__icon svg,
|
||||||
.hb-app-icon {
|
.hb-app-icon,
|
||||||
|
.hb-app-icon svg {
|
||||||
display: block;
|
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 {
|
.hb-dock-more__ico {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,19 @@ import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach } from "vitest";
|
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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue