Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
5f14259197
@ -23,6 +23,7 @@
|
||||
"src/components/magicui/*.tsx",
|
||||
"src/components/animate-ui/*.tsx",
|
||||
"src/components/tailark/*.tsx",
|
||||
"src/components/ai-elements/*.tsx",
|
||||
"src/app/[[]locale]/preview/**",
|
||||
"src/payment/types.ts",
|
||||
"src/credits/types.ts",
|
||||
@ -85,6 +86,7 @@
|
||||
"src/components/magicui/*.tsx",
|
||||
"src/components/animate-ui/*.tsx",
|
||||
"src/components/tailark/*.tsx",
|
||||
"src/components/ai-elements/*.tsx",
|
||||
"src/app/[[]locale]/preview/**",
|
||||
"src/payment/types.ts",
|
||||
"src/credits/types.ts",
|
||||
|
@ -181,6 +181,7 @@ CRON_JOBS_PASSWORD=""
|
||||
# AI
|
||||
# https://mksaas.com/docs/ai
|
||||
# -----------------------------------------------------------------------------
|
||||
AI_GATEWAY_API_KEY=""
|
||||
FAL_API_KEY=""
|
||||
FIREWORKS_API_KEY=""
|
||||
OPENAI_API_KEY=""
|
||||
|
@ -320,6 +320,10 @@
|
||||
"title": "AI Image",
|
||||
"description": "Show how to use AI to generate beautiful images"
|
||||
},
|
||||
"chat": {
|
||||
"title": "AI Chat",
|
||||
"description": "Show how to use AI to chat with your customers"
|
||||
},
|
||||
"video": {
|
||||
"title": "AI Video",
|
||||
"description": "Show how to use AI to generate amazing videos"
|
||||
@ -601,8 +605,7 @@
|
||||
"creditsAdded": "Credits have been added to your account",
|
||||
"viewTransactions": "View Credit Transactions",
|
||||
"retry": "Retry",
|
||||
|
||||
"expiringCredits": "{credits} credits expiring on {date}"
|
||||
"expiringCredits": "{credits} credits expiring in the next {days} days"
|
||||
},
|
||||
"packages": {
|
||||
"title": "Credit Packages",
|
||||
@ -1041,17 +1044,18 @@
|
||||
},
|
||||
"AIImagePage": {
|
||||
"title": "AI Image",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIChatPage": {
|
||||
"title": "AI Chat",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIVideoPage": {
|
||||
"title": "AI Video",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
},
|
||||
"AIAudioPage": {
|
||||
"title": "AI Audio",
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
||||
"content": "Working in progress"
|
||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly"
|
||||
}
|
||||
}
|
||||
|
@ -320,6 +320,10 @@
|
||||
"title": "AI 图像",
|
||||
"description": "展示如何使用 AI 生成精美图像"
|
||||
},
|
||||
"chat": {
|
||||
"title": "AI 聊天",
|
||||
"description": "展示如何使用 AI 与客户聊天"
|
||||
},
|
||||
"video": {
|
||||
"title": "AI 视频",
|
||||
"description": "展示如何使用 AI 生成惊人视频"
|
||||
@ -601,8 +605,7 @@
|
||||
"creditsAdded": "积分已添加到您的账户",
|
||||
"viewTransactions": "查看积分记录",
|
||||
"retry": "重试",
|
||||
|
||||
"expiringCredits": "{credits} 积分将在 {date} 过期"
|
||||
"expiringCredits": "{credits} 积分将在 {days} 天内过期"
|
||||
},
|
||||
"packages": {
|
||||
"title": "积分套餐",
|
||||
@ -1041,17 +1044,18 @@
|
||||
},
|
||||
"AIImagePage": {
|
||||
"title": "AI 图片",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIChatPage": {
|
||||
"title": "AI 聊天",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIVideoPage": {
|
||||
"title": "AI 视频",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
},
|
||||
"AIAudioPage": {
|
||||
"title": "AI 音频",
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
||||
"content": "正在开发中"
|
||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力"
|
||||
}
|
||||
}
|
||||
|
@ -60,6 +60,10 @@ const nextConfig: NextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'html.tailus.io',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'service.firecrawl.dev',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
@ -31,6 +31,7 @@
|
||||
"@ai-sdk/fireworks": "^1.0.0",
|
||||
"@ai-sdk/google": "^2.0.0",
|
||||
"@ai-sdk/openai": "^2.0.0",
|
||||
"@ai-sdk/react": "^2.0.22",
|
||||
"@ai-sdk/replicate": "^1.0.0",
|
||||
"@base-ui-components/react": "1.0.0-beta.0",
|
||||
"@better-fetch/fetch": "^1.1.18",
|
||||
@ -75,6 +76,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@react-email/components": "0.0.33",
|
||||
"@react-email/render": "1.0.5",
|
||||
"@stripe/stripe-js": "^5.6.0",
|
||||
@ -120,6 +122,7 @@
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-remove-scroll": "^2.6.3",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-syntax-highlighter": "^15.6.3",
|
||||
"react-tweet": "^3.2.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.1",
|
||||
@ -127,6 +130,7 @@
|
||||
"s3mini": "^0.2.0",
|
||||
"shiki": "^2.4.2",
|
||||
"sonner": "^2.0.0",
|
||||
"streamdown": "^1.0.12",
|
||||
"stripe": "^17.6.0",
|
||||
"swiper": "^11.2.5",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
@ -134,6 +138,7 @@
|
||||
"tw-animate-css": "^1.2.4",
|
||||
"use-intl": "^3.26.5",
|
||||
"use-media": "^1.5.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.17",
|
||||
"zustand": "^5.0.3"
|
||||
@ -148,6 +153,7 @@
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"knip": "^5.61.2",
|
||||
"postcss": "^8",
|
||||
|
635
pnpm-lock.yaml
generated
635
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -48,7 +48,7 @@ export const createCreditCheckoutSession = userActionClient
|
||||
...metadata,
|
||||
type: 'credit_purchase',
|
||||
packageId,
|
||||
credits: creditPackage.credits.toString(),
|
||||
credits: creditPackage.amount.toString(),
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
};
|
||||
|
@ -9,8 +9,19 @@ import { userActionClient } from '@/lib/safe-action';
|
||||
*/
|
||||
export const getCreditBalanceAction = userActionClient.action(
|
||||
async ({ ctx }) => {
|
||||
try {
|
||||
const currentUser = (ctx as { user: User }).user;
|
||||
const credits = await getUserCredits(currentUser.id);
|
||||
return { success: true, credits };
|
||||
} catch (error) {
|
||||
console.error('get credit balance error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch credit balance',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -3,11 +3,10 @@
|
||||
import { getDb } from '@/db';
|
||||
import { creditTransaction } from '@/db/schema';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
|
||||
import { userActionClient } from '@/lib/safe-action';
|
||||
import { addDays } from 'date-fns';
|
||||
import { and, eq, gte, isNotNull, lte, sql, sum } from 'drizzle-orm';
|
||||
|
||||
const CREDITS_EXPIRATION_DAYS = 31;
|
||||
import { and, eq, gt, gte, isNotNull, lte, sum } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Get credit statistics for a user
|
||||
@ -18,12 +17,14 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
||||
const userId = currentUser.id;
|
||||
|
||||
const db = await getDb();
|
||||
// Get credits expiring in the next CREDITS_EXPIRATION_DAYS days
|
||||
const expirationDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS);
|
||||
const expiringCredits = await db
|
||||
const now = new Date();
|
||||
// Get credits expiring in the next 30 days
|
||||
const expirationDaysFromNow = addDays(now, CREDITS_EXPIRATION_DAYS);
|
||||
|
||||
// Get total credits expiring in the next 30 days
|
||||
const expiringCreditsResult = await db
|
||||
.select({
|
||||
amount: sum(creditTransaction.remainingAmount),
|
||||
earliestExpiration: sql<Date>`MIN(${creditTransaction.expirationDate})`,
|
||||
totalAmount: sum(creditTransaction.remainingAmount),
|
||||
})
|
||||
.from(creditTransaction)
|
||||
.where(
|
||||
@ -31,18 +32,20 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
||||
eq(creditTransaction.userId, userId),
|
||||
isNotNull(creditTransaction.expirationDate),
|
||||
isNotNull(creditTransaction.remainingAmount),
|
||||
gte(creditTransaction.remainingAmount, 1),
|
||||
gt(creditTransaction.remainingAmount, 0),
|
||||
lte(creditTransaction.expirationDate, expirationDaysFromNow),
|
||||
gte(creditTransaction.expirationDate, new Date())
|
||||
gte(creditTransaction.expirationDate, now)
|
||||
)
|
||||
);
|
||||
|
||||
const totalExpiringCredits =
|
||||
Number(expiringCreditsResult[0]?.totalAmount) || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
expiringCredits: {
|
||||
amount: Number(expiringCredits[0]?.amount) || 0,
|
||||
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
|
||||
amount: totalExpiringCredits,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
181
src/ai/chat/components/ChatBot.tsx
Normal file
181
src/ai/chat/components/ChatBot.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import { Loader } from '@/components/ai-elements/loader';
|
||||
import { Message, MessageContent } from '@/components/ai-elements/message';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputButton,
|
||||
PromptInputModelSelect,
|
||||
PromptInputModelSelectContent,
|
||||
PromptInputModelSelectItem,
|
||||
PromptInputModelSelectTrigger,
|
||||
PromptInputModelSelectValue,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputToolbar,
|
||||
PromptInputTools,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning';
|
||||
import { Response } from '@/components/ai-elements/response';
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/source';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { GlobeIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
const models = [
|
||||
{
|
||||
name: 'GPT 4o',
|
||||
value: 'openai/gpt-4o',
|
||||
},
|
||||
{
|
||||
name: 'Deepseek R1',
|
||||
value: 'deepseek/deepseek-r1',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChatBot() {
|
||||
const [input, setInput] = useState('');
|
||||
const [model, setModel] = useState<string>(models[0].value);
|
||||
const [webSearch, setWebSearch] = useState(false);
|
||||
const { messages, sendMessage, status } = useChat();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
sendMessage(
|
||||
{ text: input },
|
||||
{
|
||||
body: {
|
||||
model: model,
|
||||
webSearch: webSearch,
|
||||
},
|
||||
}
|
||||
);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 relative size-full h-screen rounded-lg bg-muted/50">
|
||||
<div className="flex flex-col h-full">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id}>
|
||||
{message.role === 'assistant' && (
|
||||
<Sources>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'source-url':
|
||||
return (
|
||||
<>
|
||||
<SourcesTrigger
|
||||
count={
|
||||
message.parts.filter(
|
||||
(part) => part.type === 'source-url'
|
||||
).length
|
||||
}
|
||||
/>
|
||||
<SourcesContent key={`${message.id}-${i}`}>
|
||||
<Source
|
||||
key={`${message.id}-${i}`}
|
||||
href={part.url}
|
||||
title={part.url}
|
||||
/>
|
||||
</SourcesContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Sources>
|
||||
)}
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Response key={`${message.id}-${i}`}>
|
||||
{part.text}
|
||||
</Response>
|
||||
);
|
||||
case 'reasoning':
|
||||
return (
|
||||
<Reasoning
|
||||
key={`${message.id}-${i}`}
|
||||
className="w-full"
|
||||
isStreaming={status === 'streaming'}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
))}
|
||||
{status === 'submitted' && <Loader />}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<PromptInput onSubmit={handleSubmit} className="mt-4">
|
||||
<PromptInputTextarea
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
value={input}
|
||||
/>
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
<PromptInputButton
|
||||
variant={webSearch ? 'default' : 'ghost'}
|
||||
onClick={() => setWebSearch(!webSearch)}
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span>Search</span>
|
||||
</PromptInputButton>
|
||||
<PromptInputModelSelect
|
||||
onValueChange={(value) => {
|
||||
setModel(value);
|
||||
}}
|
||||
value={model}
|
||||
>
|
||||
<PromptInputModelSelectTrigger>
|
||||
<PromptInputModelSelectValue />
|
||||
</PromptInputModelSelectTrigger>
|
||||
<PromptInputModelSelectContent>
|
||||
{models.map((model) => (
|
||||
<PromptInputModelSelectItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
>
|
||||
{model.name}
|
||||
</PromptInputModelSelectItem>
|
||||
))}
|
||||
</PromptInputModelSelectContent>
|
||||
</PromptInputModelSelect>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit disabled={!input} status={status} />
|
||||
</PromptInputToolbar>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import Container from '@/components/layout/container';
|
||||
import { BlurFadeDemo } from '@/components/magicui/example/blur-fade-example';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
@ -98,9 +97,6 @@ export default async function AboutPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* image section */}
|
||||
{/* <BlurFadeDemo /> */}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
13
src/app/[locale]/(marketing)/(pages)/test/page.tsx
Normal file
13
src/app/[locale]/(marketing)/(pages)/test/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import Container from '@/components/layout/container';
|
||||
import { CreditsTest } from '@/components/test/credits-test';
|
||||
|
||||
export default async function TestPage() {
|
||||
return (
|
||||
<Container className="py-16 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* credits test */}
|
||||
<CreditsTest />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -42,10 +42,6 @@ export default async function AIAudioPage() {
|
||||
<div className="size-32 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
44
src/app/[locale]/(marketing)/ai/chat/page.tsx
Normal file
44
src/app/[locale]/(marketing)/ai/chat/page.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import ChatBot from '@/ai/chat/components/ChatBot';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { ZapIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Locale } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}): Promise<Metadata | undefined> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
||||
const pt = await getTranslations({ locale, namespace: 'AIChatPage' });
|
||||
|
||||
return constructMetadata({
|
||||
title: pt('title') + ' | ' + t('title'),
|
||||
description: pt('description'),
|
||||
canonicalUrl: getUrlWithLocale('/ai/chat', locale),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AIChatPage() {
|
||||
const t = await getTranslations('AIChatPage');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
{/* Header Section */}
|
||||
<div className="text-center space-y-6 mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
|
||||
<ZapIcon className="size-4" />
|
||||
{t('title')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Bot */}
|
||||
<ChatBot />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -26,7 +26,7 @@ export default async function AITextPage() {
|
||||
const t = await getTranslations('AITextPage');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background rounded-lg">
|
||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
{/* Header Section */}
|
||||
<div className="text-center space-y-6 mb-12">
|
||||
|
@ -42,10 +42,6 @@ export default async function AIVideoPage() {
|
||||
<div className="size-32 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -212,8 +212,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
||||
{relatedPosts && relatedPosts.length > 0 && (
|
||||
<div className="flex flex-col gap-8 mt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-lg tracking-wider font-semibold text-gradient_indigo-purple">
|
||||
<FileTextIcon className="size-4 text-primary" />
|
||||
<h2 className="text-lg tracking-wider font-semibold text-primary">
|
||||
{t('morePosts')}
|
||||
</h2>
|
||||
</div>
|
||||
|
26
src/app/api/chat/route.ts
Normal file
26
src/app/api/chat/route.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { type UIMessage, convertToModelMessages, streamText } from 'ai';
|
||||
|
||||
// Allow streaming responses up to 30 seconds
|
||||
export const maxDuration = 30;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const {
|
||||
messages,
|
||||
model,
|
||||
webSearch,
|
||||
}: { messages: UIMessage[]; model: string; webSearch: boolean } =
|
||||
await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: webSearch ? 'perplexity/sonar' : model,
|
||||
messages: convertToModelMessages(messages),
|
||||
system:
|
||||
'You are a helpful assistant that can answer questions and help with tasks',
|
||||
});
|
||||
|
||||
// send sources and reasoning back to the client
|
||||
return result.toUIMessageStreamResponse({
|
||||
sendSources: true,
|
||||
sendReasoning: true,
|
||||
});
|
||||
}
|
65
src/components/ai-elements/actions.tsx
Normal file
65
src/components/ai-elements/actions.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type ActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn('flex items-center gap-1', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Action = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
'size-9 p-1.5 text-muted-foreground hover:text-foreground relative',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
212
src/components/ai-elements/branch.tsx
Normal file
212
src/components/ai-elements/branch.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type BranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const BranchContext = createContext<BranchContextType | null>(null);
|
||||
|
||||
const useBranch = () => {
|
||||
const context = useContext(BranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Branch components must be used within Branch');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const Branch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: BranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: BranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<BranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
</BranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
|
||||
const { currentBranch, setBranches, branches } = useBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2 overflow-hidden [&>div]:pb-0',
|
||||
index === currentBranch ? 'block' : 'hidden'
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage['role'];
|
||||
};
|
||||
|
||||
export const BranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: BranchSelectorProps) => {
|
||||
const { totalBranches } = useBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 self-end px-10',
|
||||
from === 'assistant' ? 'justify-start' : 'justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const BranchPrevious = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
className={cn(
|
||||
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const BranchNext = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
className={cn(
|
||||
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-muted-foreground text-xs tabular-nums',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</span>
|
||||
);
|
||||
};
|
148
src/components/ai-elements/code-block.tsx
Normal file
148
src/components/ai-elements/code-block.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: '',
|
||||
});
|
||||
|
||||
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<SyntaxHighlighter
|
||||
className="overflow-hidden dark:hidden"
|
||||
codeTagProps={{
|
||||
className: 'font-mono text-sm',
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
background: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
}}
|
||||
language={language}
|
||||
lineNumberStyle={{
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
paddingRight: '1rem',
|
||||
minWidth: '2.5rem',
|
||||
}}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={oneLight}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
<SyntaxHighlighter
|
||||
className="hidden overflow-hidden dark:block"
|
||||
codeTagProps={{
|
||||
className: 'font-mono text-sm',
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
background: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
}}
|
||||
language={language}
|
||||
lineNumberStyle={{
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
paddingRight: '1rem',
|
||||
minWidth: '2.5rem',
|
||||
}}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={oneDark}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
|
||||
onError?.(new Error('Clipboard API not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
62
src/components/ai-elements/conversation.tsx
Normal file
62
src/components/ai-elements/conversation.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn('p-4', className)} {...props} />
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
24
src/components/ai-elements/image.tsx
Normal file
24
src/components/ai-elements/image.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Experimental_GeneratedImage } from 'ai';
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
'h-auto max-w-full overflow-hidden rounded-md',
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
287
src/components/ai-elements/inline-citation.tsx
Normal file
287
src/components/ai-elements/inline-citation.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
type CarouselApi,
|
||||
} from '@/components/ui/carousel';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export type InlineCitationProps = ComponentProps<'span'>;
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn('group inline items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<'span'>;
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn('transition-colors group-hover:bg-accent', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge
|
||||
className={cn('ml-1 rounded-full', className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{sources.length ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{' '}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
'unknown'
|
||||
)}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
|
||||
);
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />;
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn('w-full space-y-2 p-4 pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Previous"
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Next"
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<'div'> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn('space-y-1', className)} {...props}>
|
||||
{title && (
|
||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-muted border-l-2 pl-3 text-muted-foreground text-sm italic',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
96
src/components/ai-elements/loader.tsx
Normal file
96
src/components/ai-elements/loader.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
style={{ color: 'currentcolor' }}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
>
|
||||
<title>Loader</title>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 16V12"
|
||||
opacity="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
opacity="0.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
opacity="0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
opacity="0.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
opacity="0.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
opacity="0.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
opacity="0.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
opacity="0.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
opacity="0.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect fill="white" height="16" width="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex animate-spin items-center justify-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
);
|
64
src/components/ai-elements/message.tsx
Normal file
64
src/components/ai-elements/message.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UIMessage } from 'ai';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage['role'];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full items-end justify-end gap-2 py-4',
|
||||
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
|
||||
'[&>div]:max-w-[80%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
|
||||
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
|
||||
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="is-user:dark">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
src: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const MessageAvatar = ({
|
||||
src,
|
||||
name,
|
||||
className,
|
||||
...props
|
||||
}: MessageAvatarProps) => (
|
||||
<Avatar
|
||||
className={cn('size-8 ring ring-1 ring-border', className)}
|
||||
{...props}
|
||||
>
|
||||
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
|
||||
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
230
src/components/ai-elements/prompt-input.tsx
Normal file
230
src/components/ai-elements/prompt-input.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChatStatus } from 'ai';
|
||||
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
HTMLAttributes,
|
||||
KeyboardEventHandler,
|
||||
} from 'react';
|
||||
import { Children } from 'react';
|
||||
|
||||
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
|
||||
|
||||
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
|
||||
<form
|
||||
className={cn(
|
||||
'w-full divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
};
|
||||
|
||||
export const PromptInputTextarea = ({
|
||||
onChange,
|
||||
className,
|
||||
placeholder = 'What would you like to know?',
|
||||
minHeight = 48,
|
||||
maxHeight = 164,
|
||||
...props
|
||||
}: PromptInputTextareaProps) => {
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// Don't submit if IME composition is in progress
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Allow newline
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit on Enter (without Shift)
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget.form;
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className={cn(
|
||||
'w-full resize-none rounded-none border-none p-3 shadow-none outline-none ring-0',
|
||||
'field-sizing-content max-h-[6lh] bg-transparent dark:bg-transparent',
|
||||
'focus-visible:ring-0',
|
||||
className
|
||||
)}
|
||||
name="message"
|
||||
onChange={(e) => {
|
||||
onChange?.(e);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputToolbar = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputToolbarProps) => (
|
||||
<div
|
||||
className={cn('flex items-center justify-between p-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputTools = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputToolsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1',
|
||||
'[&_button:first-child]:rounded-bl-xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const PromptInputButton = ({
|
||||
variant = 'ghost',
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: PromptInputButtonProps) => {
|
||||
const newSize =
|
||||
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'shrink-0 gap-1.5 rounded-lg',
|
||||
variant === 'ghost' && 'text-muted-foreground',
|
||||
newSize === 'default' && 'px-3',
|
||||
className
|
||||
)}
|
||||
size={newSize}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
|
||||
status?: ChatStatus;
|
||||
};
|
||||
|
||||
export const PromptInputSubmit = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'icon',
|
||||
status,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <SendIcon className="size-4" />;
|
||||
|
||||
if (status === 'submitted') {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
} else if (status === 'streaming') {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
} else if (status === 'error') {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('gap-1.5 rounded-lg', className)}
|
||||
size={size}
|
||||
type="submit"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? Icon}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
|
||||
<Select {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
|
||||
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectContent = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectContentProps) => (
|
||||
<SelectContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
|
||||
|
||||
export const PromptInputModelSelectItem = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectItemProps) => (
|
||||
<SelectItem className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectValue = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectValueProps) => (
|
||||
<SelectValue className={cn(className)} {...props} />
|
||||
);
|
180
src/components/ai-elements/reasoning.tsx
Normal file
180
src/components/ai-elements/reasoning.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { createContext, memo, useContext, useEffect, useState } from 'react';
|
||||
import { Response } from './response';
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error('Reasoning components must be used within Reasoning');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: 0,
|
||||
});
|
||||
|
||||
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.round((Date.now() - startTime) / 1000));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen) {
|
||||
setIsOpen(true);
|
||||
} else if (!isStreaming && isOpen && !defaultOpen && !hasAutoClosedRef) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosedRef(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4', className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
title = 'Reasoning',
|
||||
children,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-muted-foreground text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{isStreaming || duration === 0 ? (
|
||||
<p>Thinking...</p>
|
||||
) : (
|
||||
<p>Thought for {duration} seconds</p>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Response className="grid gap-2">{children}</Response>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = 'Reasoning';
|
||||
ReasoningTrigger.displayName = 'ReasoningTrigger';
|
||||
ReasoningContent.displayName = 'ReasoningContent';
|
22
src/components/ai-elements/response.tsx
Normal file
22
src/components/ai-elements/response.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type ComponentProps, memo } from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const Response = memo(
|
||||
({ className, ...props }: ResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
Response.displayName = 'Response';
|
74
src/components/ai-elements/source.tsx
Normal file
74
src/components/ai-elements/source.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BookIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type SourcesProps = ComponentProps<'div'>;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4 text-primary text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger className="flex items-center gap-2" {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<'a'>;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
56
src/components/ai-elements/suggestion.tsx
Normal file
56
src/components/ai-elements/suggestion.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollBar,
|
||||
} from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
||||
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollBar className="hidden" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
|
||||
suggestion: string;
|
||||
onClick?: (suggestion: string) => void;
|
||||
};
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = () => {
|
||||
onClick?.(suggestion);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('cursor-pointer rounded-full px-4', className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children || suggestion}
|
||||
</Button>
|
||||
);
|
||||
};
|
94
src/components/ai-elements/task.tsx
Normal file
94
src/components/ai-elements/task.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDownIcon, SearchIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<'div'>;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<'div'>;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
defaultOpen={defaultOpen}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
|
||||
{children ?? (
|
||||
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground hover:text-foreground">
|
||||
<SearchIcon className="size-4" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
142
src/components/ai-elements/tool.tsx
Normal file
142
src/components/ai-elements/tool.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ToolUIPart } from 'ai';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { CodeBlock } from './code-block';
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4 w-full rounded-md border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
type: ToolUIPart['type'];
|
||||
state: ToolUIPart['state'];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ToolUIPart['state']) => {
|
||||
const labels = {
|
||||
'input-streaming': 'Pending',
|
||||
'input-available': 'Running',
|
||||
'output-available': 'Completed',
|
||||
'output-error': 'Error',
|
||||
} as const;
|
||||
|
||||
const icons = {
|
||||
'input-streaming': <CircleIcon className="size-4" />,
|
||||
'input-available': <ClockIcon className="size-4 animate-pulse" />,
|
||||
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
'output-error': <XCircleIcon className="size-4 text-red-600" />,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Badge className="rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-4 p-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{type}</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<'div'> & {
|
||||
input: ToolUIPart['input'];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<'div'> & {
|
||||
output: ReactNode;
|
||||
errorText: ToolUIPart['errorText'];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2 p-4', className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? 'Error' : 'Result'}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
|
||||
errorText
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: 'bg-muted/50 text-foreground'
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{output && <div>{output}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
252
src/components/ai-elements/web-preview.tsx
Normal file
252
src/components/ai-elements/web-preview.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
|
||||
export type WebPreviewContextValue = {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error('WebPreview components must be used within a WebPreview');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type WebPreviewProps = ComponentProps<'div'> & {
|
||||
defaultUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
};
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = '',
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
};
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col rounded-lg border bg-card',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<'div'>;
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn('flex items-center gap-1 border-b p-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { url, setUrl } = useWebPreview();
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? url}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
|
||||
loading?: ReactNode;
|
||||
};
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { url } = useWebPreview();
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
className={cn('size-full', className)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
||||
src={(src ?? url) || undefined}
|
||||
title="Preview"
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
|
||||
logs?: Array<{
|
||||
level: 'log' | 'warn' | 'error';
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn('border-t bg-muted/50 font-mono text-sm', className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
|
||||
variant="ghost"
|
||||
>
|
||||
Console
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
consoleOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'px-4 pb-4',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'
|
||||
)}
|
||||
>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No console output</p>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs',
|
||||
log.level === 'error' && 'text-destructive',
|
||||
log.level === 'warn' && 'text-yellow-600',
|
||||
log.level === 'log' && 'text-foreground'
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${index}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{' '}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
@ -66,7 +66,7 @@ export default function FeaturesSection() {
|
||||
<div className="grid gap-12 sm:px-12 lg:grid-cols-12 lg:gap-24 lg:px-0">
|
||||
<div className="lg:col-span-5 flex flex-col gap-8">
|
||||
<div className="lg:pr-0 text-left">
|
||||
<h3 className="text-3xl font-semibold lg:text-4xl text-gradient_indigo-purple leading-normal py-1">
|
||||
<h3 className="text-3xl font-semibold lg:text-4xl text-foreground leading-normal py-1">
|
||||
{t('title')}
|
||||
</h3>
|
||||
<p className="mt-4 text-muted-foreground">{t('description')}</p>
|
||||
|
@ -56,14 +56,13 @@ export default function HeroSection() {
|
||||
<AnimatedGroup variants={transitionVariants}>
|
||||
<LocaleLink
|
||||
href={linkIntroduction}
|
||||
className="hover:bg-background group mx-auto flex w-fit items-center gap-4 rounded-full border p-1 pl-4 shadow-md shadow-zinc-950/5 transition-colors duration-300 dark:shadow-zinc-950"
|
||||
className="hover:bg-accent group mx-auto flex w-fit items-center gap-2 rounded-full border p-1 pl-4"
|
||||
>
|
||||
<span className="text-foreground text-sm">
|
||||
{t('introduction')}
|
||||
</span>
|
||||
{/* <span className="dark:border-background block h-4 w-0.5 border-l bg-white dark:bg-zinc-700"></span> */}
|
||||
|
||||
<div className="bg-background group-hover:bg-muted size-6 overflow-hidden rounded-full duration-500">
|
||||
<div className="size-6 overflow-hidden rounded-full duration-500">
|
||||
<div className="flex w-12 -translate-x-1/2 duration-500 ease-in-out group-hover:translate-x-0">
|
||||
<span className="flex size-6">
|
||||
<ArrowRight className="m-auto size-3" />
|
||||
|
@ -20,7 +20,7 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
|
||||
|
||||
return (
|
||||
<LocaleLink href={`/blog/${post.slugs}`} className="block h-full">
|
||||
<div className="group flex flex-col border rounded-lg overflow-hidden h-full">
|
||||
<div className="group flex flex-col border border-border rounded-lg overflow-hidden h-full transition-all duration-300 ease-in-out hover:border-primary hover:shadow-lg hover:shadow-primary/20">
|
||||
{/* Image container - fixed aspect ratio */}
|
||||
<div className="group overflow-hidden relative aspect-16/9 w-full">
|
||||
{image && (
|
||||
@ -57,19 +57,7 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
|
||||
<div className="flex flex-col justify-between p-4 flex-1">
|
||||
<div>
|
||||
{/* Post title */}
|
||||
<h3 className="text-lg line-clamp-2 font-medium">
|
||||
<span
|
||||
className="bg-linear-to-r from-green-200 to-green-100
|
||||
bg-[length:0px_10px] bg-left-bottom bg-no-repeat
|
||||
transition-[background-size]
|
||||
duration-500
|
||||
hover:bg-[length:100%_3px]
|
||||
group-hover:bg-[length:100%_10px]
|
||||
dark:from-purple-800 dark:to-purple-900"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
<h3 className="text-lg line-clamp-2 font-medium">{title}</h3>
|
||||
|
||||
{/* Post excerpt */}
|
||||
<div className="mt-2">
|
||||
|
@ -35,7 +35,13 @@ export function UpgradeCard() {
|
||||
const isMember =
|
||||
paymentData?.currentPlan?.isLifetime || !!paymentData?.subscription;
|
||||
|
||||
if (!mounted || isLoading || isMember) {
|
||||
// Ensure the upgrade card is only shown when the data is loaded
|
||||
if (!mounted || isLoading || !paymentData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the user is a member, don't show the upgrade card
|
||||
if (isMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -43,7 +43,7 @@ export function HeaderSection({
|
||||
{title ? (
|
||||
<TitleComponent
|
||||
className={cn(
|
||||
'uppercase tracking-wider text-gradient_indigo-purple font-semibold font-mono',
|
||||
'uppercase tracking-wider text-primary font-semibold font-mono',
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
|
@ -37,8 +37,8 @@ const customNavigationMenuTriggerStyle = cn(
|
||||
'relative bg-transparent text-muted-foreground cursor-pointer',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-active:font-semibold data-active:bg-transparent data-active:text-foreground',
|
||||
'data-[state=open]:bg-transparent data-[state=open]:text-foreground'
|
||||
'data-active:font-semibold data-active:bg-transparent data-active:text-accent-foreground',
|
||||
'data-[state=open]:bg-transparent data-[state=open]:text-accent-foreground'
|
||||
);
|
||||
|
||||
export function Navbar({ scroll }: NavBarProps) {
|
||||
@ -132,10 +132,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center transition-colors',
|
||||
'bg-transparent text-muted-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
||||
isSubItemActive &&
|
||||
'bg-transparent text-foreground'
|
||||
'bg-transparent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{subItem.icon ? subItem.icon : null}
|
||||
@ -144,10 +144,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm font-medium text-muted-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
||||
isSubItemActive &&
|
||||
'bg-transparent text-foreground'
|
||||
'bg-transparent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{subItem.title}
|
||||
@ -156,10 +156,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-foreground/80',
|
||||
'group-focus:bg-transparent group-focus:text-foreground/80',
|
||||
'group-hover:bg-transparent group-hover:text-accent-foreground/80',
|
||||
'group-focus:bg-transparent group-focus:text-accent-foreground/80',
|
||||
isSubItemActive &&
|
||||
'bg-transparent text-foreground/80'
|
||||
'bg-transparent text-accent-foreground/80'
|
||||
)}
|
||||
>
|
||||
{subItem.description}
|
||||
@ -170,10 +170,10 @@ export function Navbar({ scroll }: NavBarProps) {
|
||||
<ArrowUpRightIcon
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-foreground',
|
||||
'group-hover:bg-transparent group-hover:text-accent-foreground',
|
||||
'group-focus:bg-transparent group-focus:text-accent-foreground',
|
||||
isSubItemActive &&
|
||||
'bg-transparent text-foreground'
|
||||
'bg-transparent text-accent-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
@ -100,7 +100,7 @@ export default function BillingCard() {
|
||||
|
||||
// Render loading skeleton if not mounted or in a loading state
|
||||
const isPageLoading = isLoadingPayment || isLoadingSession;
|
||||
if (!mounted || isPageLoading) {
|
||||
if (!mounted || isPageLoading || !paymentData) {
|
||||
return (
|
||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||
<CardHeader>
|
||||
@ -111,14 +111,14 @@ export default function BillingCard() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<Skeleton className="h-6 w-1/5" />
|
||||
<Skeleton className="h-8 w-1/5" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<Skeleton className="h-6 w-3/5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<Skeleton className="h-10 w-1/2" />
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Skeleton className="h-8 w-1/4" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
@ -139,7 +139,7 @@ export default function BillingCard() {
|
||||
{loadPaymentError?.message}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
@ -262,7 +262,7 @@ export default function BillingCard() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
{/* user is on free plan, show upgrade plan button */}
|
||||
{isFreePlan && (
|
||||
<Button variant="default" className="cursor-pointer" asChild>
|
||||
|
@ -41,11 +41,11 @@ export function CreditPackages() {
|
||||
(pkg) => !pkg.disabled && pkg.price.priceId
|
||||
);
|
||||
|
||||
// Check if user is on free plan and enableForFreePlan is false
|
||||
// Check if user is on free plan and enablePackagesForFreePlan is false
|
||||
const isFreePlan = currentPlan?.isFree === true;
|
||||
|
||||
// Check if user is on free plan and enableForFreePlan is false
|
||||
if (isFreePlan && !websiteConfig.credits.enableForFreePlan) {
|
||||
// Check if user is on free plan and enablePackagesForFreePlan is false
|
||||
if (isFreePlan && !websiteConfig.credits.enablePackagesForFreePlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ export function CreditPackages() {
|
||||
<div className="text-left">
|
||||
<div className="text-2xl font-semibold flex items-center gap-2">
|
||||
<CoinsIcon className="h-4 w-4 text-muted-foreground" />
|
||||
{creditPackage.credits.toLocaleString()}
|
||||
{creditPackage.amount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
@ -14,7 +14,7 @@ import { websiteConfig } from '@/config/website';
|
||||
import { useCreditBalance, useCreditStats } from '@/hooks/use-credits';
|
||||
import { useMounted } from '@/hooks/use-mounted';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { CREDITS_EXPIRATION_DAYS } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Routes } from '@/routes';
|
||||
import { RefreshCwIcon } from 'lucide-react';
|
||||
@ -102,13 +102,12 @@ export default function CreditsBalanceCard() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<Skeleton className="h-6 w-1/5" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<Skeleton className="h-6 w-3/5" />
|
||||
<Skeleton className="h-8 w-1/5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="">{/* show nothing */}</CardFooter>
|
||||
<CardFooter className="px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
<Skeleton className="h-6 w-3/5" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -126,7 +125,7 @@ export default function CreditsBalanceCard() {
|
||||
{balanceError?.message || statsError?.message}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
@ -146,8 +145,8 @@ export default function CreditsBalanceCard() {
|
||||
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
{/* Credits balance display */}
|
||||
<CardContent className="flex-1">
|
||||
{/* Credits balance */}
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* <CoinsIcon className="h-6 w-6 text-muted-foreground" /> */}
|
||||
@ -157,33 +156,22 @@ export default function CreditsBalanceCard() {
|
||||
</div>
|
||||
{/* <Badge variant="outline">available</Badge> */}
|
||||
</div>
|
||||
|
||||
{/* Balance information */}
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
</CardContent>
|
||||
<CardFooter className="px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
{/* Expiring credits warning */}
|
||||
{!isLoadingStats &&
|
||||
creditStats &&
|
||||
creditStats.expiringCredits.amount > 0 &&
|
||||
creditStats.expiringCredits.earliestExpiration && (
|
||||
{!isLoadingStats && creditStats && (
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
{' '}
|
||||
<div className="flex items-center gap-2 text-amber-600">
|
||||
<span>
|
||||
{t('expiringCredits', {
|
||||
credits: creditStats.expiringCredits.amount,
|
||||
date: formatDate(
|
||||
new Date(creditStats.expiringCredits.earliestExpiration)
|
||||
),
|
||||
days: CREDITS_EXPIRATION_DAYS,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="">
|
||||
{/* <Button variant="default" className="cursor-pointer" asChild>
|
||||
<LocaleLink href={Routes.SettingsCredits}>
|
||||
{t('viewTransactions')}
|
||||
</LocaleLink>
|
||||
</Button> */}
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
@ -173,7 +173,7 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-6 px-6 py-4 bg-background rounded-none">
|
||||
<CardFooter className="mt-6 px-6 py-4 bg-muted rounded-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('newsletter.hint')}
|
||||
</p>
|
||||
|
@ -171,7 +171,7 @@ export function UpdateAvatarCard({ className }: UpdateAvatarCardProps) {
|
||||
|
||||
<FormError message={error} />
|
||||
</CardContent>
|
||||
<CardFooter className="mt-auto px-6 py-4 flex justify-between items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-auto px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('avatar.recommendation')}
|
||||
</p>
|
||||
|
@ -140,7 +140,7 @@ export function UpdateNameCard({ className }: UpdateNameCardProps) {
|
||||
/>
|
||||
<FormError message={error} />
|
||||
</CardContent>
|
||||
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
<p className="text-sm text-muted-foreground">{t('name.hint')}</p>
|
||||
|
||||
<Button
|
||||
|
@ -97,7 +97,7 @@ export function DeleteAccountCard() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
@ -57,7 +58,7 @@ export function PasswordCardWrapper() {
|
||||
function PasswordSkeletonCard() {
|
||||
const t = useTranslations('Dashboard.settings.security.updatePassword');
|
||||
return (
|
||||
<Card className={cn('w-full overflow-hidden pt-6 pb-6 flex flex-col')}>
|
||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
@ -67,9 +68,10 @@ function PasswordSkeletonCard() {
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</CardContent>
|
||||
<CardFooter className="px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Skeleton className="h-8 w-1/4" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ export function ResetPasswordCard({ className }: ResetPasswordCardProps) {
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
<p className="text-sm text-muted-foreground">{t('info')}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-auto px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-auto px-6 py-4 flex justify-end items-center bg-muted rounded-none">
|
||||
<Button onClick={handleSetupPassword} className="cursor-pointer">
|
||||
{t('button')}
|
||||
</Button>
|
||||
|
@ -206,7 +206,7 @@ export function UpdatePasswordCard({ className }: UpdatePasswordCardProps) {
|
||||
/>
|
||||
<FormError message={error} />
|
||||
</CardContent>
|
||||
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-background rounded-none">
|
||||
<CardFooter className="mt-6 px-6 py-4 flex justify-between items-center bg-muted rounded-none">
|
||||
<p className="text-sm text-muted-foreground">{t('hint')}</p>
|
||||
|
||||
<Button
|
||||
|
53
src/components/test/credits-test.tsx
Normal file
53
src/components/test/credits-test.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
|
||||
import { CoinsIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function CreditsTest() {
|
||||
const { data: balance = 0, isLoading } = useCreditBalance();
|
||||
const consumeCreditsMutation = useConsumeCredits();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConsume = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await consumeCreditsMutation.mutateAsync({
|
||||
amount: 10,
|
||||
description: 'Test credit consumption',
|
||||
});
|
||||
toast.success('10 credits consumed successfully!');
|
||||
} catch (error) {
|
||||
toast.error('Failed to consume credits');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border rounded-lg space-y-4">
|
||||
<h3 className="text-lg font-semibold">Credits Store Test</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>Store Balance:</strong> {balance}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleConsume}
|
||||
disabled={loading || consumeCreditsMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
<CoinsIcon className="w-4 h-4 mr-2" />
|
||||
Consume 10 Credits
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -14,7 +14,7 @@ const badgeVariants = cva(
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70",
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
|
@ -18,7 +18,7 @@ function ScrollArea({
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
LogInIcon,
|
||||
MailIcon,
|
||||
MailboxIcon,
|
||||
MessageCircleIcon,
|
||||
NewspaperIcon,
|
||||
RocketIcon,
|
||||
ShieldCheckIcon,
|
||||
@ -95,6 +96,13 @@ export function getNavbarLinks(): NestedMenuItem[] {
|
||||
href: Routes.AIImage,
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: t('ai.items.chat.title'),
|
||||
description: t('ai.items.chat.description'),
|
||||
icon: <MessageCircleIcon className="size-4 shrink-0" />,
|
||||
href: Routes.AIChat,
|
||||
external: false,
|
||||
},
|
||||
// {
|
||||
// title: t('ai.items.video.title'),
|
||||
// description: t('ai.items.video.description'),
|
||||
|
@ -155,17 +155,17 @@ export const websiteConfig: WebsiteConfig = {
|
||||
},
|
||||
credits: {
|
||||
enableCredits: process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true',
|
||||
enableForFreePlan: false,
|
||||
enablePackagesForFreePlan: false,
|
||||
registerGiftCredits: {
|
||||
enable: true,
|
||||
credits: 50,
|
||||
amount: 50,
|
||||
expireDays: 30,
|
||||
},
|
||||
packages: {
|
||||
basic: {
|
||||
id: 'basic',
|
||||
popular: false,
|
||||
credits: 100,
|
||||
amount: 100,
|
||||
expireDays: 30,
|
||||
price: {
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC!,
|
||||
@ -177,7 +177,7 @@ export const websiteConfig: WebsiteConfig = {
|
||||
standard: {
|
||||
id: 'standard',
|
||||
popular: true,
|
||||
credits: 200,
|
||||
amount: 200,
|
||||
expireDays: 30,
|
||||
price: {
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD!,
|
||||
@ -189,7 +189,7 @@ export const websiteConfig: WebsiteConfig = {
|
||||
premium: {
|
||||
id: 'premium',
|
||||
popular: false,
|
||||
credits: 500,
|
||||
amount: 500,
|
||||
expireDays: 30,
|
||||
price: {
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM!,
|
||||
@ -201,7 +201,7 @@ export const websiteConfig: WebsiteConfig = {
|
||||
enterprise: {
|
||||
id: 'enterprise',
|
||||
popular: false,
|
||||
credits: 1000,
|
||||
amount: 1000,
|
||||
expireDays: 30,
|
||||
price: {
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!,
|
||||
|
@ -2,9 +2,9 @@ import { randomUUID } from 'crypto';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { getDb } from '@/db';
|
||||
import { creditTransaction, userCredit } from '@/db/schema';
|
||||
import { findPlanByPriceId } from '@/lib/price-plan';
|
||||
import { findPlanByPlanId, findPlanByPriceId } from '@/lib/price-plan';
|
||||
import { addDays, isAfter } from 'date-fns';
|
||||
import { and, asc, eq, gt, isNull, not, or } from 'drizzle-orm';
|
||||
import { and, asc, eq, gt, isNull, not, or, sql } from 'drizzle-orm';
|
||||
import { CREDIT_TRANSACTION_TYPE } from './types';
|
||||
|
||||
/**
|
||||
@ -49,23 +49,6 @@ export async function updateUserCredits(userId: string, credits: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's last refresh time
|
||||
* @param userId - User ID
|
||||
* @param date - Last refresh time
|
||||
*/
|
||||
export async function updateUserLastRefreshAt(userId: string, date: Date) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db
|
||||
.update(userCredit)
|
||||
.set({ lastRefreshAt: date, updatedAt: new Date() })
|
||||
.where(eq(userCredit.userId, userId));
|
||||
} catch (error) {
|
||||
console.error('updateUserLastRefreshAt, error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a credit transaction record
|
||||
* @param params - Credit transaction parameters
|
||||
@ -164,7 +147,6 @@ export async function addCredits({
|
||||
.update(userCredit)
|
||||
.set({
|
||||
currentCredits: newBalance,
|
||||
// lastRefreshAt: new Date(), // NOTE: we can not update this field here
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userCredit.userId, userId));
|
||||
@ -175,7 +157,6 @@ export async function addCredits({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
currentCredits: newBalance,
|
||||
// lastRefreshAt: new Date(), // NOTE: we can not update this field here
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
@ -375,34 +356,39 @@ export async function processExpiredCredits(userId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credits can be added for a user based on last refresh time
|
||||
* Check if specific type of credits can be added for a user based on transaction history
|
||||
* @param userId - User ID
|
||||
* @param creditType - Type of credit transaction to check
|
||||
*/
|
||||
export async function canAddMonthlyCredits(userId: string) {
|
||||
export async function canAddCreditsByType(userId: string, creditType: string) {
|
||||
const db = await getDb();
|
||||
const record = await db
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth();
|
||||
const currentYear = now.getFullYear();
|
||||
|
||||
// Check if user has already received this type of credits this month
|
||||
const existingTransaction = await db
|
||||
.select()
|
||||
.from(userCredit)
|
||||
.where(eq(userCredit.userId, userId))
|
||||
.from(creditTransaction)
|
||||
.where(
|
||||
and(
|
||||
eq(creditTransaction.userId, userId),
|
||||
eq(creditTransaction.type, creditType),
|
||||
// Check if transaction was created in the current month and year
|
||||
sql`EXTRACT(MONTH FROM ${creditTransaction.createdAt}) = ${currentMonth + 1}`,
|
||||
sql`EXTRACT(YEAR FROM ${creditTransaction.createdAt}) = ${currentYear}`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const now = new Date();
|
||||
let canAdd = false;
|
||||
|
||||
// Check if user has never received credits or it's a new month
|
||||
if (!record[0]?.lastRefreshAt) {
|
||||
canAdd = true;
|
||||
} else {
|
||||
// different month or year means new month
|
||||
const last = new Date(record[0].lastRefreshAt);
|
||||
canAdd =
|
||||
now.getMonth() !== last.getMonth() ||
|
||||
now.getFullYear() !== last.getFullYear();
|
||||
}
|
||||
|
||||
return canAdd;
|
||||
return existingTransaction.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscription credits can be added for a user based on last refresh time
|
||||
* @param userId - User ID
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add register gift credits
|
||||
* @param userId - User ID
|
||||
@ -423,7 +409,7 @@ export async function addRegisterGiftCredits(userId: string) {
|
||||
|
||||
// add register gift credits if user has not received them yet
|
||||
if (record.length === 0) {
|
||||
const credits = websiteConfig.credits.registerGiftCredits.credits;
|
||||
const credits = websiteConfig.credits.registerGiftCredits.amount;
|
||||
const expireDays = websiteConfig.credits.registerGiftCredits.expireDays;
|
||||
await addCredits({
|
||||
userId,
|
||||
@ -442,11 +428,11 @@ export async function addRegisterGiftCredits(userId: string) {
|
||||
/**
|
||||
* Add free monthly credits
|
||||
* @param userId - User ID
|
||||
* @param priceId - Price ID
|
||||
* @param planId - Plan ID
|
||||
*/
|
||||
export async function addMonthlyFreeCredits(userId: string, priceId: string) {
|
||||
export async function addMonthlyFreeCredits(userId: string, planId: string) {
|
||||
// NOTICE: make sure the free plan is not disabled and has credits enabled
|
||||
const pricePlan = findPlanByPriceId(priceId);
|
||||
const pricePlan = findPlanByPlanId(planId);
|
||||
if (
|
||||
!pricePlan ||
|
||||
pricePlan.disabled ||
|
||||
@ -455,12 +441,15 @@ export async function addMonthlyFreeCredits(userId: string, priceId: string) {
|
||||
!pricePlan.credits.enable
|
||||
) {
|
||||
console.log(
|
||||
`addMonthlyFreeCredits, no credits configured for plan ${priceId}`
|
||||
`addMonthlyFreeCredits, no credits configured for plan ${planId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const canAdd = await canAddMonthlyCredits(userId);
|
||||
const canAdd = await canAddCreditsByType(
|
||||
userId,
|
||||
CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH
|
||||
);
|
||||
const now = new Date();
|
||||
|
||||
// add credits if it's a new month
|
||||
@ -475,9 +464,6 @@ export async function addMonthlyFreeCredits(userId: string, priceId: string) {
|
||||
expireDays,
|
||||
});
|
||||
|
||||
// Update last refresh time for free monthly credits
|
||||
await updateUserLastRefreshAt(userId, now);
|
||||
|
||||
console.log(
|
||||
`addMonthlyFreeCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
|
||||
);
|
||||
@ -508,7 +494,10 @@ export async function addSubscriptionCredits(userId: string, priceId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canAdd = await canAddMonthlyCredits(userId);
|
||||
const canAdd = await canAddCreditsByType(
|
||||
userId,
|
||||
CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL
|
||||
);
|
||||
const now = new Date();
|
||||
|
||||
// Add credits if it's a new month
|
||||
@ -524,9 +513,6 @@ export async function addSubscriptionCredits(userId: string, priceId: string) {
|
||||
expireDays,
|
||||
});
|
||||
|
||||
// Update last refresh time for subscription credits
|
||||
await updateUserLastRefreshAt(userId, now);
|
||||
|
||||
console.log(
|
||||
`addSubscriptionCredits, ${credits} credits for user ${userId}, priceId: ${priceId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
|
||||
);
|
||||
@ -561,7 +547,10 @@ export async function addLifetimeMonthlyCredits(
|
||||
return;
|
||||
}
|
||||
|
||||
const canAdd = await canAddMonthlyCredits(userId);
|
||||
const canAdd = await canAddCreditsByType(
|
||||
userId,
|
||||
CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY
|
||||
);
|
||||
const now = new Date();
|
||||
|
||||
// Add credits if it's a new month
|
||||
@ -577,9 +566,6 @@ export async function addLifetimeMonthlyCredits(
|
||||
expireDays,
|
||||
});
|
||||
|
||||
// Update last refresh time for lifetime credits
|
||||
await updateUserLastRefreshAt(userId, now);
|
||||
|
||||
console.log(
|
||||
`addLifetimeMonthlyCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
|
||||
);
|
||||
|
@ -5,6 +5,7 @@ import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
|
||||
import { PlanIntervals } from '@/payment/types';
|
||||
import { addDays } from 'date-fns';
|
||||
import { and, eq, gt, inArray, isNull, lt, not, or, sql } from 'drizzle-orm';
|
||||
import { canAddCreditsByType } from './credits';
|
||||
import { CREDIT_TRANSACTION_TYPE } from './types';
|
||||
|
||||
/**
|
||||
@ -218,7 +219,6 @@ export async function batchAddMonthlyFreeCredits(userIds: string[]) {
|
||||
const userCredits = await tx
|
||||
.select({
|
||||
userId: userCredit.userId,
|
||||
lastRefreshAt: userCredit.lastRefreshAt,
|
||||
currentCredits: userCredit.currentCredits,
|
||||
})
|
||||
.from(userCredit)
|
||||
@ -229,19 +229,17 @@ export async function batchAddMonthlyFreeCredits(userIds: string[]) {
|
||||
userCredits.map((record) => [record.userId, record])
|
||||
);
|
||||
|
||||
// Filter users who can receive credits
|
||||
const eligibleUserIds = userIds.filter((userId) => {
|
||||
const record = userCreditMap.get(userId);
|
||||
if (!record?.lastRefreshAt) {
|
||||
return true; // never added credits before
|
||||
}
|
||||
// different month or year means new month
|
||||
const last = new Date(record.lastRefreshAt);
|
||||
return (
|
||||
now.getMonth() !== last.getMonth() ||
|
||||
now.getFullYear() !== last.getFullYear()
|
||||
// Check which users can receive credits based on transaction history
|
||||
const eligibleUserIds: string[] = [];
|
||||
for (const userId of userIds) {
|
||||
const canAdd = await canAddCreditsByType(
|
||||
userId,
|
||||
CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH
|
||||
);
|
||||
});
|
||||
if (canAdd) {
|
||||
eligibleUserIds.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (eligibleUserIds.length === 0) {
|
||||
console.log('batchAddMonthlyFreeCredits, no eligible users');
|
||||
@ -280,7 +278,6 @@ export async function batchAddMonthlyFreeCredits(userIds: string[]) {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
currentCredits: credits,
|
||||
lastRefreshAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
@ -297,7 +294,6 @@ export async function batchAddMonthlyFreeCredits(userIds: string[]) {
|
||||
.update(userCredit)
|
||||
.set({
|
||||
currentCredits: newBalance,
|
||||
lastRefreshAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userCredit.userId, userId));
|
||||
@ -362,7 +358,6 @@ export async function batchAddLifetimeMonthlyCredits(
|
||||
const userCredits = await tx
|
||||
.select({
|
||||
userId: userCredit.userId,
|
||||
lastRefreshAt: userCredit.lastRefreshAt,
|
||||
currentCredits: userCredit.currentCredits,
|
||||
})
|
||||
.from(userCredit)
|
||||
@ -373,19 +368,17 @@ export async function batchAddLifetimeMonthlyCredits(
|
||||
userCredits.map((record) => [record.userId, record])
|
||||
);
|
||||
|
||||
// Filter users who can receive credits
|
||||
const eligibleUserIds = userIdsForPrice.filter((userId: string) => {
|
||||
const record = userCreditMap.get(userId);
|
||||
if (!record?.lastRefreshAt) {
|
||||
return true; // never added credits before
|
||||
}
|
||||
// different month or year means new month
|
||||
const last = new Date(record.lastRefreshAt);
|
||||
return (
|
||||
now.getMonth() !== last.getMonth() ||
|
||||
now.getFullYear() !== last.getFullYear()
|
||||
// Check which users can receive credits based on transaction history
|
||||
const eligibleUserIds: string[] = [];
|
||||
for (const userId of userIdsForPrice) {
|
||||
const canAdd = await canAddCreditsByType(
|
||||
userId,
|
||||
CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY
|
||||
);
|
||||
});
|
||||
if (canAdd) {
|
||||
eligibleUserIds.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (eligibleUserIds.length === 0) {
|
||||
console.log(
|
||||
@ -426,7 +419,6 @@ export async function batchAddLifetimeMonthlyCredits(
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
currentCredits: credits,
|
||||
lastRefreshAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
@ -443,7 +435,6 @@ export async function batchAddLifetimeMonthlyCredits(
|
||||
.update(userCredit)
|
||||
.set({
|
||||
currentCredits: newBalance,
|
||||
lastRefreshAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userCredit.userId, userId));
|
||||
@ -508,7 +499,6 @@ export async function batchAddYearlyUsersMonthlyCredits(
|
||||
const userCredits = await tx
|
||||
.select({
|
||||
userId: userCredit.userId,
|
||||
lastRefreshAt: userCredit.lastRefreshAt,
|
||||
currentCredits: userCredit.currentCredits,
|
||||
})
|
||||
.from(userCredit)
|
||||
@ -519,19 +509,17 @@ export async function batchAddYearlyUsersMonthlyCredits(
|
||||
userCredits.map((record) => [record.userId, record])
|
||||
);
|
||||
|
||||
// Filter users who can receive credits
|
||||
const eligibleUserIds = userIds.filter((userId) => {
|
||||
const record = userCreditMap.get(userId);
|
||||
if (!record?.lastRefreshAt) {
|
||||
return true; // never added credits before
|
||||
}
|
||||
// different month or year means new month
|
||||
const last = new Date(record.lastRefreshAt);
|
||||
return (
|
||||
now.getMonth() !== last.getMonth() ||
|
||||
now.getFullYear() !== last.getFullYear()
|
||||
// Check which users can receive credits based on transaction history
|
||||
const eligibleUserIds: string[] = [];
|
||||
for (const userId of userIds) {
|
||||
const canAdd = await canAddCreditsByType(
|
||||
userId,
|
||||
CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL
|
||||
);
|
||||
});
|
||||
if (canAdd) {
|
||||
eligibleUserIds.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (eligibleUserIds.length === 0) {
|
||||
console.log(
|
||||
@ -572,7 +560,6 @@ export async function batchAddYearlyUsersMonthlyCredits(
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
currentCredits: credits,
|
||||
lastRefreshAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
@ -589,7 +576,6 @@ export async function batchAddYearlyUsersMonthlyCredits(
|
||||
.update(userCredit)
|
||||
.set({
|
||||
currentCredits: newBalance,
|
||||
lastRefreshAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userCredit.userId, userId));
|
||||
|
@ -26,7 +26,7 @@ export interface CreditPackagePrice {
|
||||
*/
|
||||
export interface CreditPackage {
|
||||
id: string; // Unique identifier for the package
|
||||
credits: number; // Number of credits in the package
|
||||
amount: number; // Amount of credits in the package
|
||||
price: CreditPackagePrice; // Price of the package
|
||||
popular: boolean; // Whether the package is popular
|
||||
name?: string; // Display name of the package
|
||||
|
@ -94,7 +94,7 @@ export const userCredit = pgTable("user_credit", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
currentCredits: integer("current_credits").notNull().default(0),
|
||||
lastRefreshAt: timestamp("last_refresh_at"),
|
||||
lastRefreshAt: timestamp("last_refresh_at"), // deprecated
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
|
@ -2,8 +2,10 @@ import { consumeCreditsAction } from '@/actions/consume-credits';
|
||||
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
|
||||
import { getCreditStatsAction } from '@/actions/get-credit-stats';
|
||||
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
|
||||
import { useCreditsStore } from '@/stores/credits-store';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Query keys
|
||||
export const creditsKeys = {
|
||||
@ -21,23 +23,39 @@ export const creditsKeys = {
|
||||
|
||||
// Hook to fetch credit balance
|
||||
export function useCreditBalance() {
|
||||
return useQuery({
|
||||
const updateTrigger = useCreditsStore((state) => state.updateTrigger);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: creditsKeys.balance(),
|
||||
queryFn: async () => {
|
||||
console.log('Fetching credit balance...');
|
||||
const result = await getCreditBalanceAction();
|
||||
if (!result?.data?.success) {
|
||||
throw new Error('Failed to fetch credit balance');
|
||||
throw new Error(
|
||||
result?.data?.error || 'Failed to fetch credit balance'
|
||||
);
|
||||
}
|
||||
console.log('Credit balance fetched:', result.data.credits);
|
||||
return result.data.credits || 0;
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch when updateTrigger changes
|
||||
useEffect(() => {
|
||||
if (updateTrigger > 0) {
|
||||
console.log('Credits update triggered, refetching balance...');
|
||||
query.refetch();
|
||||
}
|
||||
}, [updateTrigger, query]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// Hook to fetch credit statistics
|
||||
export function useCreditStats() {
|
||||
return useQuery({
|
||||
const updateTrigger = useCreditsStore((state) => state.updateTrigger);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: creditsKeys.stats(),
|
||||
queryFn: async () => {
|
||||
console.log('Fetching credit stats...');
|
||||
@ -49,11 +67,22 @@ export function useCreditStats() {
|
||||
return result.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch when updateTrigger changes
|
||||
useEffect(() => {
|
||||
if (updateTrigger > 0) {
|
||||
console.log('Credits update triggered, refetching stats...');
|
||||
query.refetch();
|
||||
}
|
||||
}, [updateTrigger, query]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// Hook to consume credits
|
||||
export function useConsumeCredits() {
|
||||
const queryClient = useQueryClient();
|
||||
const triggerUpdate = useCreditsStore((state) => state.triggerUpdate);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
@ -73,6 +102,9 @@ export function useConsumeCredits() {
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Trigger credits update in store to notify all components
|
||||
triggerUpdate();
|
||||
|
||||
// Invalidate credit balance and stats after consuming credits
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: creditsKeys.balance(),
|
||||
|
@ -190,7 +190,7 @@ async function onCreateUser(user: User) {
|
||||
if (
|
||||
websiteConfig.credits.enableCredits &&
|
||||
websiteConfig.credits.registerGiftCredits.enable &&
|
||||
websiteConfig.credits.registerGiftCredits.credits > 0
|
||||
websiteConfig.credits.registerGiftCredits.amount > 0
|
||||
) {
|
||||
try {
|
||||
await addRegisterGiftCredits(user.id);
|
||||
|
@ -1,2 +1,10 @@
|
||||
/**
|
||||
* in next 30 days for credits expiration
|
||||
*/
|
||||
export const CREDITS_EXPIRATION_DAYS = 30;
|
||||
|
||||
/**
|
||||
* placeholder image for blog post card
|
||||
*/
|
||||
export const PLACEHOLDER_IMAGE =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg==';
|
||||
|
@ -40,6 +40,7 @@ export enum Routes {
|
||||
// AI routes
|
||||
AIText = '/ai/text',
|
||||
AIImage = '/ai/image',
|
||||
AIChat = '/ai/chat',
|
||||
AIVideo = '/ai/video',
|
||||
AIAudio = '/ai/audio',
|
||||
|
||||
|
23
src/stores/credits-store.ts
Normal file
23
src/stores/credits-store.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface CreditsState {
|
||||
// trigger for credit updates, incremented each time credits change
|
||||
updateTrigger: number;
|
||||
// method to trigger credit updates
|
||||
triggerUpdate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits store for managing credit balance updates.
|
||||
*
|
||||
* This store provides a simple trigger mechanism to notify components
|
||||
* when credits have been consumed or updated, ensuring UI components can
|
||||
* refetch the latest credit balance.
|
||||
*/
|
||||
export const useCreditsStore = create<CreditsState>((set) => ({
|
||||
updateTrigger: 0,
|
||||
triggerUpdate: () =>
|
||||
set((state) => ({
|
||||
updateTrigger: state.updateTrigger + 1,
|
||||
})),
|
||||
}));
|
@ -12,7 +12,8 @@
|
||||
* https://tweakcn.com/
|
||||
* https://ui.pub/x/theme-gen
|
||||
*
|
||||
* default theme: Clean Slate
|
||||
* default theme: custom theme inspired by Qoder
|
||||
* https://qoder.com/
|
||||
* https://tweakcn.com/editor/theme
|
||||
*
|
||||
* NOTICE: when you change the theme, you need to check the fonts and keep the animation variables
|
||||
@ -186,107 +187,107 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.98 0.0 247.86);
|
||||
--foreground: oklch(0.28 0.04 260.03);
|
||||
--background: oklch(1.0 0 0);
|
||||
--foreground: oklch(0.1448 0 0);
|
||||
--card: oklch(1.0 0 0);
|
||||
--card-foreground: oklch(0.28 0.04 260.03);
|
||||
--card-foreground: oklch(0.1448 0 0);
|
||||
--popover: oklch(1.0 0 0);
|
||||
--popover-foreground: oklch(0.28 0.04 260.03);
|
||||
--primary: oklch(0.59 0.2 277.12);
|
||||
--primary-foreground: oklch(1.0 0 0);
|
||||
--secondary: oklch(0.93 0.01 264.53);
|
||||
--secondary-foreground: oklch(0.37 0.03 259.73);
|
||||
--muted: oklch(0.97 0.0 264.54);
|
||||
--muted-foreground: oklch(0.55 0.02 264.36);
|
||||
--accent: oklch(0.93 0.03 272.79);
|
||||
--accent-foreground: oklch(0.37 0.03 259.73);
|
||||
--destructive: oklch(0.64 0.21 25.33);
|
||||
--destructive-foreground: oklch(1.0 0 0);
|
||||
--border: oklch(0.87 0.01 258.34);
|
||||
--input: oklch(0.87 0.01 258.34);
|
||||
--ring: oklch(0.59 0.2 277.12);
|
||||
--chart-1: oklch(0.59 0.2 277.12);
|
||||
--chart-2: oklch(0.51 0.23 276.97);
|
||||
--chart-3: oklch(0.46 0.21 277.02);
|
||||
--chart-4: oklch(0.4 0.18 277.37);
|
||||
--chart-5: oklch(0.36 0.14 278.7);
|
||||
--sidebar: oklch(0.97 0.0 264.54);
|
||||
--sidebar-foreground: oklch(0.28 0.04 260.03);
|
||||
--sidebar-primary: oklch(0.59 0.2 277.12);
|
||||
--sidebar-primary-foreground: oklch(1.0 0 0);
|
||||
--sidebar-accent: oklch(0.93 0.03 272.79);
|
||||
--sidebar-accent-foreground: oklch(0.37 0.03 259.73);
|
||||
--sidebar-border: oklch(0.87 0.01 258.34);
|
||||
--sidebar-ring: oklch(0.59 0.2 277.12);
|
||||
--popover-foreground: oklch(0.1448 0 0);
|
||||
--primary: oklch(0.6271 0.1699 149.2138);
|
||||
--primary-foreground: oklch(0.9851 0 0);
|
||||
--secondary: oklch(0.9702 0 0);
|
||||
--secondary-foreground: oklch(0.2046 0 0);
|
||||
--muted: oklch(0.9702 0 0);
|
||||
--muted-foreground: oklch(0.5555 0 0);
|
||||
--accent: oklch(0.9819 0.0181 155.8263);
|
||||
--accent-foreground: oklch(0.4479 0.1083 151.3277);
|
||||
--destructive: oklch(0.5771 0.2152 27.325);
|
||||
--destructive-foreground: oklch(0.9851 0 0);
|
||||
--border: oklch(0.9219 0 0);
|
||||
--input: oklch(0.9219 0 0);
|
||||
--ring: oklch(0.7227 0.192 149.5793);
|
||||
--chart-1: oklch(0.7227 0.192 149.5793);
|
||||
--chart-2: oklch(0.5555 0 0);
|
||||
--chart-3: oklch(0.1448 0 0);
|
||||
--chart-4: oklch(0.7155 0 0);
|
||||
--chart-5: oklch(0.9219 0 0);
|
||||
--sidebar: oklch(0.9702 0 0);
|
||||
--sidebar-foreground: oklch(0.5555 0 0);
|
||||
--sidebar-primary: oklch(0.6271 0.1699 149.2138);
|
||||
--sidebar-primary-foreground: oklch(0.9851 0 0);
|
||||
--sidebar-accent: oklch(0.9819 0.0181 155.8263);
|
||||
--sidebar-accent-foreground: oklch(0.4479 0.1083 151.3277);
|
||||
--sidebar-border: oklch(0.9219 0 0);
|
||||
--sidebar-ring: oklch(0.7227 0.192 149.5793);
|
||||
--font-sans: var(--font-noto-sans);
|
||||
--font-serif: var(--font-noto-serif);
|
||||
--font-mono: var(--font-noto-sans-mono);
|
||||
--radius: 0.5rem;
|
||||
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 2px 4px -2px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 4px 6px -2px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 8px 10px -2px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
|
||||
--shadow-2xs: 0px 4px 8px 0px hsl(0 0% 0% / 0.03);
|
||||
--shadow-xs: 0px 4px 8px 0px hsl(0 0% 0% / 0.03);
|
||||
--shadow-sm: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px
|
||||
hsl(0 0% 0% / 0.05);
|
||||
--shadow: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px
|
||||
hsl(0 0% 0% / 0.05);
|
||||
--shadow-md: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 2px 4px -1px
|
||||
hsl(0 0% 0% / 0.05);
|
||||
--shadow-lg: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 4px 6px -1px
|
||||
hsl(0 0% 0% / 0.05);
|
||||
--shadow-xl: 0px 4px 8px 0px hsl(0 0% 0% / 0.05), 0px 8px 10px -1px
|
||||
hsl(0 0% 0% / 0.05);
|
||||
--shadow-2xl: 0px 4px 8px 0px hsl(0 0% 0% / 0.13);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.21 0.04 265.75);
|
||||
--foreground: oklch(0.93 0.01 255.51);
|
||||
--card: oklch(0.28 0.04 260.03);
|
||||
--card-foreground: oklch(0.93 0.01 255.51);
|
||||
--popover: oklch(0.28 0.04 260.03);
|
||||
--popover-foreground: oklch(0.93 0.01 255.51);
|
||||
--primary: oklch(0.68 0.16 276.93);
|
||||
--primary-foreground: oklch(0.21 0.04 265.75);
|
||||
--secondary: oklch(0.34 0.03 260.91);
|
||||
--secondary-foreground: oklch(0.87 0.01 258.34);
|
||||
--muted: oklch(0.28 0.04 260.03);
|
||||
--muted-foreground: oklch(0.71 0.02 261.32);
|
||||
--accent: oklch(0.37 0.03 259.73);
|
||||
--accent-foreground: oklch(0.87 0.01 258.34);
|
||||
--destructive: oklch(0.64 0.21 25.33);
|
||||
--destructive-foreground: oklch(0.21 0.04 265.75);
|
||||
--border: oklch(0.45 0.03 256.8);
|
||||
--input: oklch(0.45 0.03 256.8);
|
||||
--ring: oklch(0.68 0.16 276.93);
|
||||
--chart-1: oklch(0.68 0.16 276.93);
|
||||
--chart-2: oklch(0.59 0.2 277.12);
|
||||
--chart-3: oklch(0.51 0.23 276.97);
|
||||
--chart-4: oklch(0.46 0.21 277.02);
|
||||
--chart-5: oklch(0.4 0.18 277.37);
|
||||
--sidebar: oklch(0.28 0.04 260.03);
|
||||
--sidebar-foreground: oklch(0.93 0.01 255.51);
|
||||
--sidebar-primary: oklch(0.68 0.16 276.93);
|
||||
--sidebar-primary-foreground: oklch(0.21 0.04 265.75);
|
||||
--sidebar-accent: oklch(0.37 0.03 259.73);
|
||||
--sidebar-accent-foreground: oklch(0.87 0.01 258.34);
|
||||
--sidebar-border: oklch(0.45 0.03 256.8);
|
||||
--sidebar-ring: oklch(0.68 0.16 276.93);
|
||||
--background: oklch(0.1591 0 0);
|
||||
--foreground: oklch(0.9702 0 0);
|
||||
--card: oklch(0.2046 0 0);
|
||||
--card-foreground: oklch(0.9702 0 0);
|
||||
--popover: oklch(0.2046 0 0);
|
||||
--popover-foreground: oklch(0.9702 0 0);
|
||||
--primary: oklch(0.8003 0.1821 151.711);
|
||||
--primary-foreground: oklch(0.1591 0 0);
|
||||
--secondary: oklch(0.9702 0 0);
|
||||
--secondary-foreground: oklch(0.2046 0 0);
|
||||
--muted: oklch(0.2686 0 0);
|
||||
--muted-foreground: oklch(0.7155 0 0);
|
||||
--accent: oklch(0.2638 0.0276 154.8977);
|
||||
--accent-foreground: oklch(0.8003 0.1821 151.711);
|
||||
--destructive: oklch(0.3958 0.1331 25.723);
|
||||
--destructive-foreground: oklch(0.9702 0 0);
|
||||
--border: oklch(0.4 0 0);
|
||||
--input: oklch(0.4 0 0);
|
||||
--ring: oklch(0.8003 0.1821 151.711);
|
||||
--chart-1: oklch(0.8003 0.1821 151.711);
|
||||
--chart-2: oklch(0.7155 0 0);
|
||||
--chart-3: oklch(0.9702 0 0);
|
||||
--chart-4: oklch(0.4386 0 0);
|
||||
--chart-5: oklch(0.2686 0 0);
|
||||
--sidebar: oklch(0.22 0 0);
|
||||
--sidebar-foreground: oklch(0.7155 0 0);
|
||||
--sidebar-primary: oklch(0.8003 0.1821 151.711);
|
||||
--sidebar-primary-foreground: oklch(0.1591 0 0);
|
||||
--sidebar-accent: oklch(0.2638 0.0276 154.8977);
|
||||
--sidebar-accent-foreground: oklch(0.8003 0.1821 151.711);
|
||||
--sidebar-border: oklch(0.2686 0 0);
|
||||
--sidebar-ring: oklch(0.8003 0.1821 151.711);
|
||||
--font-sans: var(--font-noto-sans);
|
||||
--font-serif: var(--font-noto-serif);
|
||||
--font-mono: var(--font-noto-sans-mono);
|
||||
--radius: 0.5rem;
|
||||
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px
|
||||
--shadow-2xs: 0px 4px 8px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0px 4px 8px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 1px 2px -2px
|
||||
--shadow: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 2px 4px -2px
|
||||
--shadow-md: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 4px 6px -2px
|
||||
--shadow-lg: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 4px 6px -1px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.1), 0px 8px 10px -2px
|
||||
--shadow-xl: 0px 4px 8px 0px hsl(0 0% 0% / 0.1), 0px 8px 10px -1px
|
||||
hsl(0 0% 0% / 0.1);
|
||||
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
|
||||
--shadow-2xl: 0px 4px 8px 0px hsl(0 0% 0% / 0.25);
|
||||
}
|
||||
|
||||
/*
|
||||
|
4
src/types/index.d.ts
vendored
4
src/types/index.d.ts
vendored
@ -169,10 +169,10 @@ export interface PriceConfig {
|
||||
*/
|
||||
export interface CreditsConfig {
|
||||
enableCredits: boolean; // Whether to enable credits
|
||||
enableForFreePlan: boolean; // Whether to enable purchase credits for free plan users
|
||||
enablePackagesForFreePlan: boolean;// Whether to enable purchase credits for free plan users
|
||||
registerGiftCredits: {
|
||||
enable: boolean; // Whether to enable register gift credits
|
||||
credits: number; // The number of credits to give to the user
|
||||
amount: number; // The amount of credits to give to the user
|
||||
expireDays?: number; // The number of days to expire the credits, undefined means no expire
|
||||
};
|
||||
packages: Record<string, CreditPackage>; // Packages indexed by ID
|
||||
|
Loading…
Reference in New Issue
Block a user