diff --git a/apps/web/app/components/chat.tsx b/apps/web/app/components/chat.tsx index 9facb539..67020157 100644 --- a/apps/web/app/components/chat.tsx +++ b/apps/web/app/components/chat.tsx @@ -1,15 +1,19 @@ "use client"; +import { useRef } from "react"; import { SidebarTrigger } from "@multica/ui/components/ui/sidebar"; import { ChatInput } from "@multica/ui/components/chat-input"; import { MemoizedMarkdown } from "@multica/ui/components/markdown"; import { useDeviceStore } from "@multica/store"; import { useMessages } from "../hooks/use-messages"; +import { useScrollFade } from "../hooks/use-scroll-fade"; import { cn } from "@multica/ui/lib/utils"; export function Chat() { const deviceId = useDeviceStore((s) => s.deviceId); const messages = useMessages(); + const mainRef = useRef(null); + const fadeStyle = useScrollFade(mainRef); return (
@@ -20,7 +24,7 @@ export function Chat() { -
+
{messages.map((msg) => (
-
+
diff --git a/apps/web/app/hooks/use-scroll-fade.ts b/apps/web/app/hooks/use-scroll-fade.ts new file mode 100644 index 00000000..6dc5b929 --- /dev/null +++ b/apps/web/app/hooks/use-scroll-fade.ts @@ -0,0 +1,67 @@ +import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react"; + +/** + * Returns a dynamic maskImage style based on scroll position. + * - At top → fade bottom only + * - At bottom → fade top only + * - In middle → fade both + * - No overflow → undefined (no mask) + */ +export function useScrollFade( + ref: RefObject, + fadeSize = 32 +): CSSProperties | undefined { + const [fade, setFade] = useState<"none" | "top" | "bottom" | "both">("none"); + + const update = useCallback(() => { + const el = ref.current; + if (!el) return; + + const { scrollTop, scrollHeight, clientHeight } = el; + const scrollable = scrollHeight - clientHeight; + + if (scrollable <= 0) { + setFade("none"); + return; + } + + const atTop = scrollTop <= 1; + const atBottom = scrollTop >= scrollable - 1; + + if (atTop && atBottom) setFade("none"); + else if (atTop) setFade("bottom"); + else if (atBottom) setFade("top"); + else setFade("both"); + }, [ref]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + update(); + + el.addEventListener("scroll", update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(el); + + return () => { + el.removeEventListener("scroll", update); + ro.disconnect(); + }; + }, [ref, update]); + + if (fade === "none") return undefined; + + const top = fade === "top" || fade === "both" ? `transparent 0%, black ${fadeSize}px` : "black 0%"; + const bottom = + fade === "bottom" || fade === "both" + ? `black calc(100% - ${fadeSize}px), transparent 100%` + : "black 100%"; + + const gradient = `linear-gradient(to bottom, ${top}, ${bottom})`; + + return { + maskImage: gradient, + WebkitMaskImage: gradient, + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index 9553827b..cf897d65 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@multica/store": "workspace:*", "@multica/ui": "workspace:*", "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", diff --git a/packages/store/package.json b/packages/store/package.json index 2e9a547d..a25fe7f5 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -4,9 +4,11 @@ "private": true, "type": "module", "exports": { + ".": "./src/index.ts", "./*": "./src/*.ts" }, "dependencies": { + "uuid": "^13.0.0", "zustand": "catalog:" }, "devDependencies": { diff --git a/packages/store/src/device.ts b/packages/store/src/device.ts new file mode 100644 index 00000000..9f38e3b9 --- /dev/null +++ b/packages/store/src/device.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { v7 as uuidv7 } from 'uuid' + +interface DeviceState { + deviceId: string +} + +export const useDeviceStore = create()( + persist( + () => ({ + deviceId: uuidv7(), + }), + { name: 'multica-device' } + ) +) diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 778a5959..dfb71b12 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1 +1,2 @@ export { useCounterStore } from './counter' +export { useDeviceStore } from './device' diff --git a/packages/ui/src/components/chat-input.tsx b/packages/ui/src/components/chat-input.tsx index 7e0441dc..3559a532 100644 --- a/packages/ui/src/components/chat-input.tsx +++ b/packages/ui/src/components/chat-input.tsx @@ -17,7 +17,7 @@ export function ChatInput() { }; return ( -
+