diff --git a/package-lock.json b/package-lock.json index 1d2157f..2a9612e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "vite-react-typescript-starter", "version": "0.0.0", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -24,6 +25,7 @@ "@react-three/drei": "^9.120.3", "@react-three/fiber": "^8.17.10", "@supabase/supabase-js": "^2.47.0", + "@tailwindcss/line-clamp": "^0.4.4", "@types/three": "^0.170.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -1088,6 +1090,34 @@ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz", + "integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", @@ -2483,6 +2513,15 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20" } }, + "node_modules/@tailwindcss/line-clamp": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz", + "integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", diff --git a/package.json b/package.json index 4e081c6..5c0111d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -26,6 +27,7 @@ "@react-three/drei": "^9.120.3", "@react-three/fiber": "^8.17.10", "@supabase/supabase-js": "^2.47.0", + "@tailwindcss/line-clamp": "^0.4.4", "@types/three": "^0.170.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/src/components/chat/Message.tsx b/src/components/chat/Message.tsx index 58c5eee..ac198f9 100644 --- a/src/components/chat/Message.tsx +++ b/src/components/chat/Message.tsx @@ -1,69 +1,47 @@ -import React from 'react'; -import { formatDistanceToNow } from 'date-fns'; +import React, { useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { Copy, Check, BookOpen, Link as LinkIcon, ThumbsUp, ThumbsDown } from 'lucide-react'; import { cn } from '../../lib/utils'; import { Button } from '../ui/Button'; -import { Avatar, AvatarFallback } from '../ui/Avatar'; import { Tooltip } from '../ui/Tooltip'; +import { ThumbsUp, ThumbsDown, Copy, Check, User, Bot, Wrench } from 'lucide-react'; +import { Avatar, AvatarFallback } from '../ui/Avatar'; import { Separator } from '../ui/separator'; import type { ChatMessage } from '../../types/supabase'; -import type { Components } from 'react-markdown'; interface MessageProps { message: ChatMessage; + isLast?: boolean; } -interface CodeBlockProps { - language: string; - value: string; -} - -interface Citation { - text: string; - source: string; -} - -function extractCitations(content: string): { cleanContent: string; citations: Citation[] } { - const citationRegex = /\[([^\]]+)\]\(((?:(?!\)\[).)+)\)/g; - const citations: Citation[] = []; - const cleanContent = content.replace(citationRegex, (_, text, source) => { - citations.push({ text, source }); - return text; - }); - return { cleanContent, citations }; -} - -function extractUsedTool(content: string): { cleanContent: string; usedTools: string[] } { - const usedToolRegex = /^\*\*Used Tool(?:s)?: ([^*]+)\*\*\n\n/; - const match = content.match(usedToolRegex); +function extractUsedTools(content: string): { cleanContent: string; usedTools: string[] } { + const toolRegex = /^\*\*Used Tools?:([^*]+)\*\*\n\n/; + const match = content.match(toolRegex); if (match) { - const usedToolsString = match[1]; - const usedTools = usedToolsString.split(',').map(tool => tool.trim()); - const cleanContent = content.replace(usedToolRegex, ''); + const toolsString = match[1].trim(); + // Simply split by newlines and clean up each line + const usedTools = toolsString + .split('\n') + .map(tool => tool.trim()) + .filter(tool => tool.length > 0); + + const cleanContent = content.replace(toolRegex, ''); return { cleanContent, usedTools }; } return { cleanContent: content, usedTools: [] }; } -export function Message({ message }: MessageProps) { - const [copied, setCopied] = React.useState(false); +export const Message: React.FC = ({ message, isLast }) => { + const isUser = message.role === 'user'; const [messageCopied, setMessageCopied] = React.useState(false); const [isLiked, setIsLiked] = React.useState(false); const [isDisliked, setIsDisliked] = React.useState(false); - const { cleanContent, citations } = extractCitations(message.content); - const { cleanContent: finalContent, usedTools } = extractUsedTool(cleanContent); - const handleCopy = async (text: string) => { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; + const { cleanContent, usedTools } = useMemo(() => + extractUsedTools(message.content), [message.content] + ); const handleMessageCopy = async () => { try { @@ -83,150 +61,74 @@ export function Message({ message }: MessageProps) { setIsDisliked(!isDisliked); setIsLiked(false); } - // Here you could send feedback to your backend - }; - - const CodeBlock = React.memo(({ language, value }: CodeBlockProps) => ( -
-
- - - -
-
- {language} - {value.split('\n').length} lines -
- - {value.replace(/\n$/, '')} - -
- )); - - const code: Components['code'] = ({ className, children, ...props }) => { - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : ''; - const value = String(children).replace(/\n$/, ''); - - if (!className) { - return ( - - {children} - - ); - } - - return ; - }; - - const components: Components = { - code, - h1: ({ children }) => ( -

- {children} -

- ), - h2: ({ children }) => ( -

- {children} -

- ), - h3: ({ children }) => ( -

- {children} -

- ), }; return (
- {message.role === 'assistant' && ( - - - AI - - - )} + + + {isUser ? 'You' : 'AI'} + + -
-
- - {finalContent} - - - {message.role === 'assistant' && citations.length > 0 && ( - <> - -
-
- - Sources -
- {citations.map((citation, index) => ( -
- - - {citation.text} - {citation.source} - +
+
+
+ ( +
+
                   
- ))} -
- - )} + ), + code: ({ node, inline, ...props }) => + inline ? ( + + ) : ( + + ), + }} + > + {cleanContent} + +
- {message.role === 'assistant' && usedTools.length > 0 && ( + {!isUser && usedTools.length > 0 && ( <> - -
-
- - Used Tools + +
+
+ + Used Tools:
{usedTools.map((tool, index) => ( -
- - - {tool} - +
+ {tool}
))}
@@ -234,13 +136,12 @@ export function Message({ message }: MessageProps) { )}
-
- - {formatDistanceToNow(new Date(message.created_at), { addSuffix: true })} - - - {message.role === 'assistant' && ( -
+
+ {!isUser && ( + <> - - - -
+ )} + + +
- - {message.role === 'user' && ( - - - You - - - )}
); -} +}; diff --git a/src/components/ui/AlertDialog.tsx b/src/components/ui/AlertDialog.tsx new file mode 100644 index 0000000..33b3a1b --- /dev/null +++ b/src/components/ui/AlertDialog.tsx @@ -0,0 +1,138 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" +import { cn } from "../../lib/utils" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/lib/chat-service.ts b/src/lib/chat-service.ts index c224b47..36acffe 100644 --- a/src/lib/chat-service.ts +++ b/src/lib/chat-service.ts @@ -1,4 +1,5 @@ -import type { ChatInstance, ChatMessage } from '../types/supabase'; +import type { ChatInstance, ChatMessage, N8NChatHistory } from '../types/supabase'; +import { supabase } from './supabase'; // Helper function to generate UUIDs const generateId = () => crypto.randomUUID(); @@ -71,6 +72,39 @@ const sendToN8N = async (sessionId: string, message: string): Promise { + const sessionMap = new Map(); + + // Group messages by session_id + messages.forEach(msg => { + const existing = sessionMap.get(msg.session_id) || []; + sessionMap.set(msg.session_id, [...existing, msg]); + }); + + // Convert each session group into a ChatInstance + return Array.from(sessionMap.entries()).map(([sessionId, messages]) => { + const sortedMessages = messages.sort((a, b) => a.id - b.id); + const firstMessage = sortedMessages[0]; + + return { + id: sessionId, + created_at: '', + user_id: 'system', + title: firstMessage.message.content.slice(0, 50) + '...', + last_message_at: '', + }; + }); +}; + +interface ChatStats { + messageCount: number; + firstMessage: string; + lastMessage: string; + humanMessages: number; + aiMessages: number; +} + export const chatService = { async createChatInstance(userId: string, title: string): Promise { try { @@ -122,10 +156,14 @@ export const chatService = { async getChatInstances(userId: string): Promise { try { - const chats = getFromStorage('chat_instances'); - return chats - .filter(chat => chat.user_id === userId) - .sort((a, b) => new Date(b.last_message_at).getTime() - new Date(a.last_message_at).getTime()); + const { data: messages, error } = await supabase + .from('n8n_chat_histories') + .select('*') + .order('id', { ascending: false }); + + if (error) throw error; + + return createChatInstanceFromMessages(messages); } catch (error) { console.error('Error fetching chat instances:', error); return []; @@ -134,10 +172,26 @@ export const chatService = { async getChatMessages(chatId: string): Promise { try { - const messages = getFromStorage('chat_messages'); - return messages - .filter(message => message.chat_id === chatId) - .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + const { data: messages, error } = await supabase + .from('n8n_chat_histories') + .select('*') + .eq('session_id', chatId) + .order('id', { ascending: true }); + + if (error) throw error; + + // Convert N8NChatHistory to ChatMessage format + return messages.map(msg => ({ + id: msg.id.toString(), + chat_id: msg.session_id, + content: msg.message.content, + role: msg.message.type === 'human' ? 'user' : 'assistant', + created_at: new Date(msg.id).toISOString(), // Using id as timestamp approximation + metadata: { + ...msg.message.response_metadata, + ...msg.message.additional_kwargs + } + })); } catch (error) { console.error('Error fetching chat messages:', error); return []; @@ -157,7 +211,7 @@ export const chatService = { chat_id: chatId, content, role, - created_at: new Date().toISOString(), + created_at: '', // Empty string for timestamp metadata, }; @@ -178,7 +232,7 @@ export const chatService = { chat_id: chatId, content: n8nResponse.response.content, role: 'assistant', - created_at: new Date().toISOString(), + created_at: '', // Empty string for timestamp metadata: { ...n8nResponse.response.metadata, make_response_id: userMessage.id // Link to the user's message @@ -200,7 +254,7 @@ export const chatService = { chat_id: chatId, content: 'Sorry, I encountered an error processing your request.', role: 'assistant', - created_at: new Date().toISOString(), + created_at: '', // Empty string for timestamp metadata: { error: 'Failed to process message', make_response_id: userMessage.id @@ -212,17 +266,9 @@ export const chatService = { } } - // Update last_message_at in chat instance - const chats = getFromStorage('chat_instances'); - const chatIndex = chats.findIndex(chat => chat.id === chatId); - if (chatIndex !== -1) { - chats[chatIndex].last_message_at = new Date().toISOString(); - setInStorage('chat_instances', chats); - } - // Return all messages for this chat const updatedMessages = messages.filter(msg => msg.chat_id === chatId) - .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + .sort((a, b) => messages.indexOf(a) - messages.indexOf(b)); // Sort by order of addition console.log('Returning updated messages:', updatedMessages); return updatedMessages; } catch (error) { @@ -231,18 +277,40 @@ export const chatService = { } }, + async getChatStats(chatId: string): Promise { + try { + const { data: messages, error } = await supabase + .from('n8n_chat_histories') + .select('*') + .eq('session_id', chatId) + .order('id', { ascending: true }); + + if (error) throw error; + if (!messages || messages.length === 0) return null; + + const stats: ChatStats = { + messageCount: messages.length, + firstMessage: messages[0].message.content, + lastMessage: messages[messages.length - 1].message.content, + humanMessages: messages.filter(m => m.message.type === 'human').length, + aiMessages: messages.filter(m => m.message.type === 'ai').length + }; + + return stats; + } catch (error) { + console.error('Error getting chat stats:', error); + return null; + } + }, + async deleteChatInstance(chatId: string): Promise { try { - // Delete all messages for this chat - const messages = getFromStorage('chat_messages'); - const filteredMessages = messages.filter(message => message.chat_id !== chatId); - setInStorage('chat_messages', filteredMessages); - - // Delete the chat instance - const chats = getFromStorage('chat_instances'); - const filteredChats = chats.filter(chat => chat.id !== chatId); - setInStorage('chat_instances', filteredChats); + const { error } = await supabase + .from('n8n_chat_histories') + .delete() + .eq('session_id', chatId); + if (error) throw error; return true; } catch (error) { console.error('Error deleting chat instance:', error); diff --git a/src/pages/dashboard/ask.tsx b/src/pages/dashboard/ask.tsx index b02a9fc..b6611fd 100644 --- a/src/pages/dashboard/ask.tsx +++ b/src/pages/dashboard/ask.tsx @@ -57,23 +57,42 @@ export default function AskQuestion() { try { // Don't try to load chat if we're using a temporary ID if (chatId.startsWith('temp-')) { + // For new chats, create a temporary chat instance + const tempChat: ChatInstance = { + id: chatId, + created_at: '', + user_id: 'system', + title: 'New Chat', + last_message_at: '', + }; + setChat(tempChat); + setMessages([]); return; } - // Load chat instance - const chatInstance = await chatService.getChatInstance(chatId); - if (!chatInstance) { - setError('Chat not found'); - return; - } - setChat(chatInstance); - - // Load messages + // Load messages for existing chats const messages = await chatService.getChatMessages(chatId); setMessages(messages); + + // For existing chats, either load from storage or create from messages + const existingChat = await chatService.getChatInstance(chatId); + if (existingChat) { + setChat(existingChat); + } else if (messages.length > 0) { + // Create a chat instance from the first message + const firstMessage = messages[0]; + const chatInstance: ChatInstance = { + id: chatId, + created_at: '', + user_id: 'system', + title: firstMessage.content.slice(0, 50) + '...', + last_message_at: '', + }; + setChat(chatInstance); + } } catch (err) { - setError('Failed to load chat'); console.error('Error loading chat:', err); + setError('Failed to load chat'); } }; @@ -109,18 +128,11 @@ export default function AskQuestion() { try { let currentChatId: string; - // If no chat exists or we're on the base ask page, create a new one - if (!chatId || chatId === undefined) { - const title = generateChatTitle(question.trim()); - const newChat = await chatService.createChatInstance(user.id, title); - if (!newChat) { - throw new Error('Failed to create chat instance'); - } - currentChatId = newChat.id; - setChat(newChat); - setIsNewChat(false); + // If no chat exists or we're starting a new chat, generate a new session ID + if (!chatId || chatId.startsWith('temp-')) { + currentChatId = crypto.randomUUID(); // Update URL with the real chat ID - navigate(`/dashboard/ask/${newChat.id}`, { replace: true }); + navigate(`/dashboard/ask/${currentChatId}`, { replace: true }); } else { currentChatId = chatId; } diff --git a/src/pages/dashboard/history.tsx b/src/pages/dashboard/history.tsx index 0c01651..1eefc39 100644 --- a/src/pages/dashboard/history.tsx +++ b/src/pages/dashboard/history.tsx @@ -1,23 +1,55 @@ import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { MessageSquare, Trash2 } from 'lucide-react'; +import { MessageSquare, Trash2, User, Bot } from 'lucide-react'; import { Button } from '../../components/ui/Button'; import { useAuth } from '../../contexts/AuthContext'; import { chatService } from '../../lib/chat-service'; import type { ChatInstance } from '../../types/supabase'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../../components/ui/AlertDialog"; + +interface ChatWithStats extends ChatInstance { + stats?: { + messageCount: number; + humanMessages: number; + aiMessages: number; + }; + isLoadingStats?: boolean; +} export default function StudyHistory() { const { user } = useAuth(); - const [chats, setChats] = useState([]); + const [chats, setChats] = useState([]); const [loading, setLoading] = useState(true); + const [chatToDelete, setChatToDelete] = useState(null); useEffect(() => { if (!user) return; const loadChats = async () => { const chatInstances = await chatService.getChatInstances(user.id); - setChats(chatInstances); + setChats(chatInstances.map(chat => ({ ...chat, isLoadingStats: true }))); setLoading(false); + + // Load stats for each chat + for (const chat of chatInstances) { + const stats = await chatService.getChatStats(chat.id); + setChats(prev => + prev.map(c => + c.id === chat.id + ? { ...c, stats, isLoadingStats: false } + : c + ) + ); + } }; loadChats(); @@ -28,6 +60,7 @@ export default function StudyHistory() { if (success) { setChats(prev => prev.filter(chat => chat.id !== chatId)); } + setChatToDelete(null); }; if (loading) { @@ -70,13 +103,32 @@ export default function StudyHistory() { {chats.map((chat) => (
-

{chat.title}

-

- Last updated: {new Date(chat.last_message_at).toLocaleString()} -

+

{chat.title}

+ + {chat.isLoadingStats ? ( +
+
+
+
+ ) : chat.stats ? ( +
+
+ + {chat.stats.messageCount} +
+
+ + {chat.stats.humanMessages} +
+
+ + {chat.stats.aiMessages} +
+
+ ) : null}
)} + + setChatToDelete(null)}> + + + Are you sure? + + This action cannot be undone. This will permanently delete the chat and all its messages. + + + + Cancel + chatToDelete && handleDelete(chatToDelete)} + > + Delete + + + +
); } diff --git a/src/types/supabase.ts b/src/types/supabase.ts index 9842aed..bdb18ee 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -1,9 +1,9 @@ export interface ChatInstance { id: string; - created_at: string; + created_at: string; // Keep this as it's required by Supabase but we'll use empty string user_id: string; title: string; - last_message_at: string; + last_message_at: string; // Keep this as it's required by Supabase but we'll use empty string } export interface ChatMessage { @@ -11,13 +11,26 @@ export interface ChatMessage { chat_id: string; content: string; role: 'user' | 'assistant'; - created_at: string; + created_at: string; // Keep this as it's required by Supabase but we'll use empty string metadata?: { make_response_id?: string; error?: string; }; } +export interface N8NChatHistory { + id: number; + session_id: string; + message: { + type: 'human' | 'ai'; + content: string; + tool_calls?: any[]; + additional_kwargs: Record; + response_metadata: Record; + invalid_tool_calls?: any[]; + }; +} + export type Database = { public: { Tables: { @@ -31,6 +44,11 @@ export type Database = { Insert: Omit; Update: Partial>; }; + n8n_chat_histories: { + Row: N8NChatHistory; + Insert: Omit; + Update: Partial>; + }; }; }; }; diff --git a/tailwind.config.js b/tailwind.config.js index 87561a4..79302af 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,7 @@ /** @type {import('tailwindcss').Config} */ import forms from '@tailwindcss/forms' import scrollbarHide from 'tailwind-scrollbar-hide' +import lineClamp from '@tailwindcss/line-clamp' export default { darkMode: ["class"], @@ -75,5 +76,5 @@ export default { }, }, }, - plugins: [forms, scrollbarHide], + plugins: [forms, scrollbarHide, lineClamp], }