diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 8f3f77f..50eb1ba 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index 2c35ed0..8ec5477 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/layout/AccountMenu.tsx b/apps/web/src/components/layout/AccountMenu.tsx index 8542705..9dab25a 100644 --- a/apps/web/src/components/layout/AccountMenu.tsx +++ b/apps/web/src/components/layout/AccountMenu.tsx @@ -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() { {name} - ▾ + diff --git a/apps/web/src/components/layout/BellMenu.tsx b/apps/web/src/components/layout/BellMenu.tsx index 04510b6..4e61c6a 100644 --- a/apps/web/src/components/layout/BellMenu.tsx +++ b/apps/web/src/components/layout/BellMenu.tsx @@ -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")} > - {/* 簡化鈴鐺:幾何,不用 emoji */} - - - - + {unread > 0 ? ( {unread > 9 ? "9+" : unread} diff --git a/apps/web/src/components/layout/MobileDock.tsx b/apps/web/src/components/layout/MobileDock.tsx index 05c3d6b..44da632 100644 --- a/apps/web/src/components/layout/MobileDock.tsx +++ b/apps/web/src/components/layout/MobileDock.tsx @@ -103,7 +103,11 @@ export function MobileDock() { {t(item.labelKey)} - {active ? : null} + {active ? ( + + + + ) : null} ); diff --git a/apps/web/src/components/layout/PageHelp.tsx b/apps/web/src/components/layout/PageHelp.tsx index ebb4fe3..b84cfaf 100644 --- a/apps/web/src/components/layout/PageHelp.tsx +++ b/apps/web/src/components/layout/PageHelp.tsx @@ -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")} (? )`} > - ? + ); } diff --git a/apps/web/src/components/radar/ExplorePanel.tsx b/apps/web/src/components/radar/ExplorePanel.tsx index 53c4f89..5310f4e 100644 --- a/apps/web/src/components/radar/ExplorePanel.tsx +++ b/apps/web/src/components/radar/ExplorePanel.tsx @@ -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 }) { > {term} - × + )) diff --git a/apps/web/src/components/studio/ImageAttach.tsx b/apps/web/src/components/studio/ImageAttach.tsx index 3e6dab0..032b57d 100644 --- a/apps/web/src/components/studio/ImageAttach.tsx +++ b/apps/web/src/components/studio/ImageAttach.tsx @@ -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)} > - × + ))} diff --git a/apps/web/src/components/ui/AppIcons.test.tsx b/apps/web/src/components/ui/AppIcons.test.tsx new file mode 100644 index 0000000..3d8cf40 --- /dev/null +++ b/apps/web/src/components/ui/AppIcons.test.tsx @@ -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( + , + ); + 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( + , + ); + 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); + }); +}); diff --git a/apps/web/src/components/ui/AppIcons.tsx b/apps/web/src/components/ui/AppIcons.tsx index efc0d35..2fc4b00 100644 --- a/apps/web/src/components/ui/AppIcons.tsx +++ b/apps/web/src/components/ui/AppIcons.tsx @@ -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(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, "name">; + /** 密碼眼睛等:點一下才播,不要 hover 就劃掉 */ + play?: "hover" | "click"; + /** 一開始停在最後一格(例如 close 顯示 X) */ + reverse?: boolean; +}; + +const glyphs: Record = { + 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(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 ( - - {glyphs[name]} - + /> ); } - -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 ( - - - - ); -} - -const glyphs: Record = { - // 今日:圓角日曆 + 星 - today: ( - <> - - - - - - ), - // 帳號:雙圓 + 柔弧(島民) - crew: ( - <> - - - - - - - ), - // 創作:羽毛筆 + 星 - studio: ( - <> - - - - - - ), - // 海巡:水晶球 / 羅盤弧 + 十字光 - scout: ( - <> - - - - - - - ), - // 雷達:同心圓掃描 - radar: ( - <> - - - - - - - ), - // 名單:看板卡片 - crm: ( - <> - - - - - - ), - // 發送:紙飛機 + 軌跡星 - outbox: ( - <> - - - - - ), - // 任務:魔法卷軸/手提袋簡化 - jobs: ( - <> - - - - - - ), - // 品牌:旗幟堡壘簡化 + 星 - brands: ( - <> - - - - - - ), - // 商機政策:盾牌 + 核取,代表全域回覆與篩選規則 - policy: ( - <> - - - - ), - playbooks: ( - <> - - - - ), - insights: ( - <> - - - - - ), - benchmark: ( - <> - - - - - ), - utm: ( - <> - - - - - ), - more: ( - <> - - - - - ), - spark: ( - <> - - - - ), - // 送出:置中紙飛機(小圓鈕專用,無星芒避免破圖感) - send: ( - <> - - - - ), -}; diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx index 54974cf..567b05c 100644 --- a/apps/web/src/components/ui/Input.tsx +++ b/apps/web/src/components/ui/Input.tsx @@ -1,4 +1,5 @@ import { useState, type InputHTMLAttributes } from "react"; +import { AppIcon } from "./AppIcons"; type Props = InputHTMLAttributes & { label?: string; @@ -54,7 +55,7 @@ export function Input({ aria-pressed={visible} onClick={() => setVisible((v) => !v)} > -