Compare commits
2 Commits
15051cfa7a
...
f767bb5175
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f767bb5175 | ||
|
|
444aa37be2 |
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react';
|
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { GradualBlur } from '@/components/ui/gradual-blur';
|
||||||
|
|
||||||
const SUGGESTION_CHIPS = [
|
const SUGGESTION_CHIPS = [
|
||||||
{ icon: ListTodo, label: "What's on my plate today?" },
|
{ icon: ListTodo, label: "What's on my plate today?" },
|
||||||
@@ -16,6 +17,28 @@ const SUGGESTION_CHIPS = [
|
|||||||
{ icon: Lightbulb, label: 'Suggest next actions' },
|
{ icon: Lightbulb, label: 'Suggest next actions' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
function getTimeGreeting(): string {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 12) return 'Good morning,';
|
||||||
|
if (hour < 17) return 'Good afternoon,';
|
||||||
|
return 'Good evening,';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entrance animation: staggered fade-up */
|
||||||
|
const stagger = {
|
||||||
|
hidden: {},
|
||||||
|
show: { transition: { staggerChildren: 0.08 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 16 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.45, ease: [0.25, 0.1, 0.25, 1] as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
interface AIChatPanelProps {
|
interface AIChatPanelProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
isHomePage?: boolean;
|
isHomePage?: boolean;
|
||||||
@@ -50,6 +73,9 @@ export function AIChatPanel({
|
|||||||
const briefContentRef = useRef('');
|
const briefContentRef = useRef('');
|
||||||
const hasFiredBrief = useRef(false);
|
const hasFiredBrief = useRef(false);
|
||||||
|
|
||||||
|
const [briefExpanded, setBriefExpanded] = useState(false);
|
||||||
|
const [briefDismissed, setBriefDismissed] = useState(false);
|
||||||
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const briefMutation = trpc.ai.dailyBrief.useMutation();
|
const briefMutation = trpc.ai.dailyBrief.useMutation();
|
||||||
@@ -118,10 +144,82 @@ export function AIChatPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
||||||
|
{/* Sticky brief toast — anchored at top when chatting */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -80, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -80, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||||
|
className="sticky top-0 z-30 flex justify-center px-4 pt-3 pb-1"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/80 backdrop-blur-xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
|
||||||
|
{/* Toast header — always visible */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||||
|
<Sparkles size={14} className="text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold tracking-wide text-foreground">Daily Brief</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => setBriefExpanded((v) => !v)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBriefDismissed(true)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Collapsed: one-line preview */}
|
||||||
|
{!briefExpanded && (
|
||||||
|
<div className="px-4 pb-3 -mt-1">
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{dailyBrief.replace(/[#*_~`>\-]/g, '').slice(0, 120)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Expanded: full brief content */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{briefExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
|
||||||
|
<ChatMarkdown content={dailyBrief} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Scrollable messages area */}
|
{/* Scrollable messages area */}
|
||||||
|
<div className="relative flex-1 min-h-0">
|
||||||
|
{/* Gradual blur at the bottom of messages */}
|
||||||
|
{hasMessages && (
|
||||||
|
<GradualBlur
|
||||||
|
position="bottom"
|
||||||
|
strength={0.6}
|
||||||
|
height="4rem"
|
||||||
|
divCount={10}
|
||||||
|
curve="ease-out"
|
||||||
|
opacity={0.8}
|
||||||
|
zIndex={20}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="flex-1 min-h-0"
|
className="h-full"
|
||||||
viewportRef={messagesContainerRef}
|
viewportRef={messagesContainerRef}
|
||||||
|
scrollbarClassName={hasMessages ? 'z-30' : undefined}
|
||||||
viewportClassName={
|
viewportClassName={
|
||||||
isHomePage && !hasMessages
|
isHomePage && !hasMessages
|
||||||
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
||||||
@@ -130,25 +228,45 @@ export function AIChatPanel({
|
|||||||
>
|
>
|
||||||
{/* Home page initial state: greeting + brief */}
|
{/* Home page initial state: greeting + brief */}
|
||||||
{isHomePage && !hasMessages && (
|
{isHomePage && !hasMessages && (
|
||||||
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8">
|
<motion.div
|
||||||
<div className="flex flex-col gap-8">
|
className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
|
||||||
{/* Greeting + brief grouped closely */}
|
variants={stagger}
|
||||||
<div className="flex flex-col gap-1">
|
initial="hidden"
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
animate="show"
|
||||||
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}>
|
>
|
||||||
✦ Hello, {userName}
|
<div className="flex flex-col" style={{ gap: 'clamp(2.5rem, 4vh, 4rem)' }}>
|
||||||
|
{/* Greeting — editorial hero moment */}
|
||||||
|
<motion.div variants={fadeUp} className="flex flex-col gap-1">
|
||||||
|
<span
|
||||||
|
className="font-light tracking-wide text-muted-foreground"
|
||||||
|
style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
|
||||||
|
>
|
||||||
|
{getTimeGreeting()}
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
className="font-bold leading-[1.05]"
|
||||||
|
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
|
||||||
|
>
|
||||||
|
{userName}
|
||||||
|
<span className="text-primary ml-3 inline-block">✦</span>
|
||||||
</h1>
|
</h1>
|
||||||
<Badge variant="secondary">
|
{dueCount > 0 && (
|
||||||
{dueCount} Task{dueCount !== 1 ? 's' : ''} due
|
<p
|
||||||
</Badge>
|
className="text-muted-foreground mt-2"
|
||||||
</div>
|
style={{ fontSize: 'clamp(0.875rem, 1.2vw, 1.125rem)' }}
|
||||||
|
>
|
||||||
|
<span className="text-foreground font-medium">{dueCount}</span>
|
||||||
|
{' '}task{dueCount !== 1 ? 's' : ''} due today
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Daily brief */}
|
{/* Daily brief */}
|
||||||
<div>
|
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||||
{hasTokenQuery.data === false ? (
|
{hasTokenQuery.data === false ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-2">
|
<div className="flex flex-col items-start gap-3 py-2">
|
||||||
<KeyRound size={24} className="text-muted-foreground" />
|
<KeyRound size={20} className="text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
|
||||||
Configure your AI provider in Settings to enable the daily brief.
|
Configure your AI provider in Settings to enable the daily brief.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||||
@@ -156,23 +274,22 @@ export function AIChatPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : briefLoading && !dailyBrief ? (
|
) : briefLoading && !dailyBrief ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-4 w-3/4" />
|
<Skeleton className="h-5 w-3/4" />
|
||||||
<Skeleton className="h-4 w-1/2" />
|
<Skeleton className="h-5 w-1/2" />
|
||||||
<Skeleton className="h-4 w-2/3" />
|
<Skeleton className="h-5 w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
) : dailyBrief ? (
|
) : dailyBrief ? (
|
||||||
<ChatMarkdown content={dailyBrief} />
|
<ChatMarkdown content={dailyBrief} size="lg" />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
|
||||||
Your daily brief will appear here.
|
Your daily brief will appear here.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inline input + suggestion chips */}
|
{/* Input + suggestion links */}
|
||||||
<div>
|
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
input={input}
|
input={input}
|
||||||
isStreaming={isStreaming || briefLoading}
|
isStreaming={isStreaming || briefLoading}
|
||||||
@@ -180,35 +297,32 @@ export function AIChatPanel({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2 mt-4">
|
<div className="flex flex-col gap-0.5 mt-5">
|
||||||
{SUGGESTION_CHIPS.map((chip) => (
|
{SUGGESTION_CHIPS.map((chip) => (
|
||||||
<button
|
<button
|
||||||
key={chip.label}
|
key={chip.label}
|
||||||
type="button"
|
type="button"
|
||||||
className="group flex items-center gap-2 rounded-full border border-border/50 bg-background/60 backdrop-blur-lg px-4 py-2 text-sm text-foreground shadow-sm ring-1 ring-border/20 transition-all hover:shadow-md hover:-translate-y-0.5 hover:border-ring/40 cursor-pointer"
|
className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
|
||||||
|
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
|
||||||
onClick={() => setInput(chip.label)}
|
onClick={() => setInput(chip.label)}
|
||||||
>
|
>
|
||||||
<chip.icon size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
<chip.icon
|
||||||
|
size={16}
|
||||||
|
className="shrink-0 transition-colors duration-200 group-hover:text-primary"
|
||||||
|
/>
|
||||||
<span>{chip.label}</span>
|
<span>{chip.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Home page with messages: brief stays, then messages */}
|
{/* Home page with messages: brief stays, then messages */}
|
||||||
{isHomePage && hasMessages && (
|
{isHomePage && hasMessages && (
|
||||||
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-32">
|
<div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Brief persists */}
|
|
||||||
{dailyBrief && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<ChatMarkdown content={dailyBrief} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chat messages */}
|
{/* Chat messages */}
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
@@ -269,11 +383,11 @@ export function AIChatPanel({
|
|||||||
|
|
||||||
{/* Non-home messages */}
|
{/* Non-home messages */}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Fixed input — pinned to the bottom (hidden on initial state) */}
|
{/* Fixed input — pinned to the bottom, above the blur */}
|
||||||
{hasMessages && (
|
{hasMessages && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-5 pt-16 pointer-events-none">
|
<div className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
|
||||||
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/60 to-background/90" />
|
|
||||||
<div className="relative pointer-events-auto mx-auto max-w-3xl">
|
<div className="relative pointer-events-auto mx-auto max-w-3xl">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
input={input}
|
input={input}
|
||||||
@@ -332,9 +446,9 @@ function ChatInput({
|
|||||||
|
|
||||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||||
|
|
||||||
export function ChatMarkdown({ content }: { content: string }) {
|
export function ChatMarkdown({ content, size = 'sm' }: { content: string; size?: 'sm' | 'lg' }) {
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
<div className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
109
src/renderer/components/ui/gradual-blur.tsx
Normal file
109
src/renderer/components/ui/gradual-blur.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
type Position = 'top' | 'bottom';
|
||||||
|
|
||||||
|
interface GradualBlurProps {
|
||||||
|
/** Edge to attach the blur overlay */
|
||||||
|
position?: Position;
|
||||||
|
/** Base blur strength multiplier */
|
||||||
|
strength?: number;
|
||||||
|
/** Overlay height (CSS value) */
|
||||||
|
height?: string;
|
||||||
|
/** Number of stacked blur layers (higher = smoother) */
|
||||||
|
divCount?: number;
|
||||||
|
/** Use exponential progression for stronger end blur */
|
||||||
|
exponential?: boolean;
|
||||||
|
/** Distribution curve: linear | ease-out */
|
||||||
|
curve?: 'linear' | 'ease-out';
|
||||||
|
/** Opacity applied to each blur layer */
|
||||||
|
opacity?: number;
|
||||||
|
/** z-index for the overlay */
|
||||||
|
zIndex?: number;
|
||||||
|
/** Additional class names */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGradientDirection = (position: Position) =>
|
||||||
|
position === 'top' ? 'to top' : 'to bottom';
|
||||||
|
|
||||||
|
export function GradualBlur({
|
||||||
|
position = 'top',
|
||||||
|
strength = 2,
|
||||||
|
height = '6rem',
|
||||||
|
divCount = 5,
|
||||||
|
exponential = false,
|
||||||
|
curve = 'linear',
|
||||||
|
opacity = 1,
|
||||||
|
zIndex = 10,
|
||||||
|
className = '',
|
||||||
|
}: GradualBlurProps) {
|
||||||
|
const blurDivs = useMemo(() => {
|
||||||
|
const divs: React.ReactNode[] = [];
|
||||||
|
const increment = 100 / divCount;
|
||||||
|
const direction = getGradientDirection(position);
|
||||||
|
|
||||||
|
const curveFunc = curve === 'ease-out'
|
||||||
|
? (p: number) => 1 - Math.pow(1 - p, 2)
|
||||||
|
: (p: number) => p;
|
||||||
|
|
||||||
|
for (let i = 1; i <= divCount; i++) {
|
||||||
|
let progress = i / divCount;
|
||||||
|
progress = curveFunc(progress);
|
||||||
|
|
||||||
|
let blurValue: number;
|
||||||
|
if (exponential) {
|
||||||
|
blurValue = Math.pow(2, progress * 4) * 0.0625 * strength;
|
||||||
|
} else {
|
||||||
|
blurValue = progress * strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p1 = Math.round((increment * i - increment) * 10) / 10;
|
||||||
|
const p2 = Math.round(increment * i * 10) / 10;
|
||||||
|
const p3 = Math.round((increment * i + increment) * 10) / 10;
|
||||||
|
const p4 = Math.round((increment * i + increment * 2) * 10) / 10;
|
||||||
|
|
||||||
|
let gradient = `transparent ${p1}%, black ${p2}%`;
|
||||||
|
if (p3 <= 100) gradient += `, black ${p3}%`;
|
||||||
|
if (p4 <= 100) gradient += `, transparent ${p4}%`;
|
||||||
|
|
||||||
|
const maskImage = `linear-gradient(${direction}, ${gradient})`;
|
||||||
|
|
||||||
|
divs.push(
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
maskImage,
|
||||||
|
WebkitMaskImage: maskImage,
|
||||||
|
backdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
|
||||||
|
WebkitBackdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
|
||||||
|
opacity,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return divs;
|
||||||
|
}, [position, strength, divCount, exponential, curve, opacity]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
[position]: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
|
{blurDivs}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,10 +8,12 @@ function ScrollArea({
|
|||||||
children,
|
children,
|
||||||
viewportRef,
|
viewportRef,
|
||||||
viewportClassName,
|
viewportClassName,
|
||||||
|
scrollbarClassName,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||||
viewportRef?: React.Ref<HTMLDivElement>;
|
viewportRef?: React.Ref<HTMLDivElement>;
|
||||||
viewportClassName?: string;
|
viewportClassName?: string;
|
||||||
|
scrollbarClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
@@ -29,7 +31,7 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
<ScrollBar />
|
<ScrollBar className={scrollbarClassName} />
|
||||||
<ScrollAreaPrimitive.Corner />
|
<ScrollAreaPrimitive.Corner />
|
||||||
</ScrollAreaPrimitive.Root>
|
</ScrollAreaPrimitive.Root>
|
||||||
)
|
)
|
||||||
@@ -45,7 +47,7 @@ function ScrollBar({
|
|||||||
data-slot="scroll-area-scrollbar"
|
data-slot="scroll-area-scrollbar"
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex touch-none p-px transition-colors select-none",
|
"flex touch-none p-px transition-colors select-none z-50",
|
||||||
orientation === "vertical" &&
|
orientation === "vertical" &&
|
||||||
"h-full w-2.5 border-l border-l-transparent",
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
orientation === "horizontal" &&
|
orientation === "horizontal" &&
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
@import '@fontsource/geist/300.css';
|
||||||
@import '@fontsource/geist/400.css';
|
@import '@fontsource/geist/400.css';
|
||||||
@import '@fontsource/geist/500.css';
|
@import '@fontsource/geist/500.css';
|
||||||
@import '@fontsource/geist/600.css';
|
@import '@fontsource/geist/600.css';
|
||||||
|
@import '@fontsource/geist/700.css';
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|||||||
Reference in New Issue
Block a user